@kopai/ui 0.0.5 → 0.2.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 +825 -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,88 @@
1
+ import { useState } from "react";
2
+ import type { ParsedTrace } from "../types.js";
3
+ import { formatDuration, formatTimestamp } from "../utils/time.js";
4
+
5
+ export interface TraceHeaderProps {
6
+ trace: ParsedTrace;
7
+ }
8
+
9
+ export function TraceHeader({ trace }: TraceHeaderProps) {
10
+ const [copied, setCopied] = useState(false);
11
+
12
+ const rootSpan = trace.rootSpans[0];
13
+ const rootServiceName = rootSpan?.serviceName ?? "unknown";
14
+ const rootSpanName = rootSpan?.name ?? "unknown";
15
+ const totalDuration = trace.maxTimeMs - trace.minTimeMs;
16
+
17
+ const handleCopyTraceId = async () => {
18
+ try {
19
+ await navigator.clipboard.writeText(trace.traceId);
20
+ setCopied(true);
21
+ setTimeout(() => setCopied(false), 2000);
22
+ } catch (err) {
23
+ console.error("Failed to copy trace ID:", err);
24
+ }
25
+ };
26
+
27
+ return (
28
+ <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>
34
+ <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"
38
+ >
39
+ {trace.traceId.slice(0, 16)}...
40
+ </button>
41
+ {copied && (
42
+ <span className="text-xs text-green-600 dark:text-green-400 font-medium">
43
+ Copied!
44
+ </span>
45
+ )}
46
+ </div>
47
+
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>
58
+
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>
67
+
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>
76
+
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>
86
+ </div>
87
+ );
88
+ }
@@ -0,0 +1,25 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { TraceTimeline } from "./index.js";
3
+ import { mockTraceRows, mockErrorTraceRows } from "../__fixtures__/traces.js";
4
+
5
+ const meta: Meta<typeof TraceTimeline> = {
6
+ title: "Observability/TraceTimeline",
7
+ component: TraceTimeline,
8
+ decorators: [
9
+ (Story) => (
10
+ <div style={{ height: "600px" }}>
11
+ <Story />
12
+ </div>
13
+ ),
14
+ ],
15
+ };
16
+ export default meta;
17
+ type Story = StoryObj<typeof TraceTimeline>;
18
+
19
+ export const Default: Story = { args: { rows: mockTraceRows } };
20
+ export const ErrorTrace: Story = { args: { rows: mockErrorTraceRows } };
21
+ export const Loading: Story = { args: { rows: [], isLoading: true } };
22
+ export const Error: Story = {
23
+ args: { rows: [], error: new globalThis.Error("Failed to fetch traces") },
24
+ };
25
+ export const Empty: Story = { args: { rows: [] } };
@@ -0,0 +1,478 @@
1
+ /**
2
+ * TraceTimeline - Accepts OtelTracesRow[] and renders trace visualization.
3
+ * Transforms denormalized rows to SpanNode tree internally.
4
+ */
5
+
6
+ import { useMemo, useState, useRef, useEffect, useCallback } from "react";
7
+ import { useVirtualizer } from "@tanstack/react-virtual";
8
+ import type { denormalizedSignals } from "@kopai/core";
9
+ type OtelTracesRow = denormalizedSignals.OtelTracesRow;
10
+ import type { SpanNode, ParsedTrace } from "../types.js";
11
+ import { flattenTree, getAllSpanIds } from "../utils/flatten-tree.js";
12
+ import {
13
+ calculateRelativeTime,
14
+ calculateRelativeDuration,
15
+ formatDuration,
16
+ } from "../utils/time.js";
17
+ import { TraceHeader } from "./TraceHeader.js";
18
+ import { SpanRow } from "./SpanRow.js";
19
+ import { DetailPane } from "./DetailPane/index.js";
20
+ import { LoadingSkeleton } from "../shared/LoadingSkeleton.js";
21
+ import { useRegisterShortcuts } from "../../KeyboardShortcuts/index.js";
22
+ import { TRACE_VIEWER_SHORTCUTS } from "./shortcuts.js";
23
+
24
+ export interface TraceTimelineProps {
25
+ rows: OtelTracesRow[];
26
+ onSpanClick?: (span: SpanNode) => void;
27
+ selectedSpanId?: string;
28
+ isLoading?: boolean;
29
+ error?: Error;
30
+ }
31
+
32
+ /** Transform OtelTracesRow[] to ParsedTrace */
33
+ function buildTrace(rows: OtelTracesRow[]): ParsedTrace | null {
34
+ if (rows.length === 0) return null;
35
+
36
+ // Pass 1: Build SpanNode lookup + trace bounds
37
+ const spanById = new Map<string, SpanNode>();
38
+ let minTimeMs = Infinity;
39
+ let maxTimeMs = -Infinity;
40
+ let traceId = "";
41
+
42
+ for (const row of rows) {
43
+ const startMs = parseInt(row.Timestamp, 10) / 1e6;
44
+ const durationNs = row.Duration ? parseInt(row.Duration, 10) : 0;
45
+ const durationMs = durationNs / 1e6;
46
+ const endMs = startMs + durationMs;
47
+
48
+ // Zip parallel arrays for events
49
+ const events: SpanNode["events"] = [];
50
+ const eventNames = row["Events.Name"] ?? [];
51
+ const eventTimestamps = row["Events.Timestamp"] ?? [];
52
+ const eventAttributes = row["Events.Attributes"] ?? [];
53
+ for (let i = 0; i < eventNames.length; i++) {
54
+ events.push({
55
+ timeUnixMs: eventTimestamps[i]
56
+ ? parseInt(eventTimestamps[i]!, 10) / 1e6
57
+ : startMs,
58
+ name: eventNames[i] ?? "",
59
+ attributes: (eventAttributes[i] as Record<string, unknown>) ?? {},
60
+ });
61
+ }
62
+
63
+ // Zip parallel arrays for links
64
+ const links: SpanNode["links"] = [];
65
+ const linkTraceIds = row["Links.TraceId"] ?? [];
66
+ const linkSpanIds = row["Links.SpanId"] ?? [];
67
+ const linkAttributes = row["Links.Attributes"] ?? [];
68
+ for (let i = 0; i < linkTraceIds.length; i++) {
69
+ links.push({
70
+ traceId: linkTraceIds[i] ?? "",
71
+ spanId: linkSpanIds[i] ?? "",
72
+ attributes: (linkAttributes[i] as Record<string, unknown>) ?? {},
73
+ });
74
+ }
75
+
76
+ const span: SpanNode = {
77
+ spanId: row.SpanId,
78
+ parentSpanId: row.ParentSpanId || undefined,
79
+ traceId: row.TraceId,
80
+ name: row.SpanName ?? "",
81
+ startTimeUnixMs: startMs,
82
+ endTimeUnixMs: endMs,
83
+ durationMs,
84
+ kind: row.SpanKind ?? "INTERNAL",
85
+ status: row.StatusCode ?? "UNSET",
86
+ statusMessage: row.StatusMessage,
87
+ serviceName: row.ServiceName ?? "unknown",
88
+ attributes: row.SpanAttributes ?? {},
89
+ resourceAttributes: row.ResourceAttributes ?? {},
90
+ events,
91
+ links,
92
+ children: [],
93
+ };
94
+
95
+ spanById.set(span.spanId, span);
96
+ minTimeMs = Math.min(minTimeMs, startMs);
97
+ maxTimeMs = Math.max(maxTimeMs, endMs);
98
+ if (!traceId) traceId = span.traceId;
99
+ }
100
+
101
+ if (spanById.size === 0) return null;
102
+
103
+ // Pass 2: Build tree
104
+ const rootSpans: SpanNode[] = [];
105
+ for (const [, span] of spanById) {
106
+ if (span.parentSpanId === span.spanId) {
107
+ rootSpans.push(span);
108
+ continue;
109
+ }
110
+ if (!span.parentSpanId || !spanById.has(span.parentSpanId)) {
111
+ rootSpans.push(span);
112
+ } else {
113
+ spanById.get(span.parentSpanId)!.children.push(span);
114
+ }
115
+ }
116
+
117
+ // Sort children by start time
118
+ for (const [, span] of spanById) {
119
+ span.children.sort((a, b) => a.startTimeUnixMs - b.startTimeUnixMs);
120
+ }
121
+ rootSpans.sort((a, b) => a.startTimeUnixMs - b.startTimeUnixMs);
122
+
123
+ return {
124
+ traceId,
125
+ rootSpans,
126
+ minTimeMs,
127
+ maxTimeMs,
128
+ totalSpanCount: spanById.size,
129
+ };
130
+ }
131
+
132
+ function isSpanAncestorOf(
133
+ potentialAncestor: SpanNode,
134
+ descendantId: string,
135
+ flattenedSpans: Array<{ span: SpanNode; level: number }>
136
+ ): boolean {
137
+ const descendantItem = flattenedSpans.find(
138
+ (item) => item.span.spanId === descendantId
139
+ );
140
+ if (!descendantItem) return false;
141
+
142
+ let current: SpanNode | undefined = descendantItem.span;
143
+ while (current?.parentSpanId) {
144
+ if (current.parentSpanId === potentialAncestor.spanId) return true;
145
+ const parentItem = flattenedSpans.find(
146
+ (item) => item.span.spanId === current!.parentSpanId
147
+ );
148
+ current = parentItem?.span;
149
+ }
150
+ return false;
151
+ }
152
+
153
+ export function TraceTimeline({
154
+ rows,
155
+ onSpanClick,
156
+ selectedSpanId: externalSelectedSpanId,
157
+ isLoading,
158
+ error,
159
+ }: TraceTimelineProps) {
160
+ useRegisterShortcuts("trace-viewer", TRACE_VIEWER_SHORTCUTS);
161
+
162
+ const [collapsedIds, setCollapsedIds] = useState<Set<string>>(new Set());
163
+ const [internalSelectedSpanId, setInternalSelectedSpanId] = useState<
164
+ string | null
165
+ >(null);
166
+ const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
167
+ const selectedSpanId = externalSelectedSpanId ?? internalSelectedSpanId;
168
+ const scrollRef = useRef<HTMLDivElement>(null);
169
+ const announcementRef = useRef<HTMLDivElement>(null);
170
+
171
+ const parsedTrace = useMemo(() => buildTrace(rows), [rows]);
172
+
173
+ const flattenedSpans = useMemo(() => {
174
+ if (!parsedTrace) return [];
175
+ return flattenTree(parsedTrace.rootSpans, collapsedIds);
176
+ }, [parsedTrace, collapsedIds]);
177
+
178
+ const virtualizer = useVirtualizer({
179
+ count: flattenedSpans.length,
180
+ getScrollElement: () => scrollRef.current,
181
+ estimateSize: () => 32,
182
+ overscan: 5,
183
+ });
184
+
185
+ const handleToggleCollapse = (spanId: string) => {
186
+ setCollapsedIds((prev) => {
187
+ const next = new Set(prev);
188
+ if (next.has(spanId)) next.delete(spanId);
189
+ else next.add(spanId);
190
+ return next;
191
+ });
192
+ };
193
+
194
+ const handleSpanClick = useCallback(
195
+ (span: SpanNode) => {
196
+ setInternalSelectedSpanId(span.spanId);
197
+ onSpanClick?.(span);
198
+ if (announcementRef.current) {
199
+ announcementRef.current.textContent = `Selected span: ${span.name}, duration: ${formatDuration(span.durationMs)}`;
200
+ }
201
+ },
202
+ [onSpanClick]
203
+ );
204
+
205
+ const handleExpandAll = useCallback(() => {
206
+ setCollapsedIds(new Set());
207
+ }, []);
208
+
209
+ const handleCollapseAll = useCallback(() => {
210
+ if (!parsedTrace) return;
211
+ setCollapsedIds(new Set(getAllSpanIds(parsedTrace.rootSpans)));
212
+ }, [parsedTrace]);
213
+
214
+ const handleNavigateUp = useCallback(() => {
215
+ if (flattenedSpans.length === 0) return;
216
+ const currentIndex = flattenedSpans.findIndex(
217
+ (item) => item.span.spanId === selectedSpanId
218
+ );
219
+ if (currentIndex > 0) {
220
+ const prevItem = flattenedSpans[currentIndex - 1];
221
+ if (prevItem) handleSpanClick(prevItem.span);
222
+ } else if (currentIndex === -1 && flattenedSpans.length > 0) {
223
+ const lastItem = flattenedSpans[flattenedSpans.length - 1];
224
+ if (lastItem) handleSpanClick(lastItem.span);
225
+ }
226
+ }, [flattenedSpans, selectedSpanId, handleSpanClick]);
227
+
228
+ const handleNavigateDown = useCallback(() => {
229
+ if (flattenedSpans.length === 0) return;
230
+ const currentIndex = flattenedSpans.findIndex(
231
+ (item) => item.span.spanId === selectedSpanId
232
+ );
233
+ if (currentIndex >= 0 && currentIndex < flattenedSpans.length - 1) {
234
+ const nextItem = flattenedSpans[currentIndex + 1];
235
+ if (nextItem) handleSpanClick(nextItem.span);
236
+ } else if (currentIndex === -1 && flattenedSpans.length > 0) {
237
+ const firstItem = flattenedSpans[0];
238
+ if (firstItem) handleSpanClick(firstItem.span);
239
+ }
240
+ }, [flattenedSpans, selectedSpanId, handleSpanClick]);
241
+
242
+ const handleCollapseExpand = useCallback(
243
+ (collapse: boolean) => {
244
+ if (!selectedSpanId) return;
245
+ const selectedItem = flattenedSpans.find(
246
+ (item) => item.span.spanId === selectedSpanId
247
+ );
248
+ if (!selectedItem || selectedItem.span.children.length === 0) return;
249
+ if (collapse) {
250
+ setCollapsedIds((prev) => new Set([...prev, selectedItem.span.spanId]));
251
+ } else {
252
+ setCollapsedIds((prev) => {
253
+ const next = new Set(prev);
254
+ next.delete(selectedItem.span.spanId);
255
+ return next;
256
+ });
257
+ }
258
+ },
259
+ [selectedSpanId, flattenedSpans]
260
+ );
261
+
262
+ const handleDeselect = useCallback(() => {
263
+ setInternalSelectedSpanId(null);
264
+ }, []);
265
+
266
+ useEffect(() => {
267
+ if (!selectedSpanId) return;
268
+ const selectedIndex = flattenedSpans.findIndex(
269
+ (item) => item.span.spanId === selectedSpanId
270
+ );
271
+ if (selectedIndex !== -1) {
272
+ virtualizer.scrollToIndex(selectedIndex, {
273
+ align: "center",
274
+ behavior: "smooth",
275
+ });
276
+ }
277
+ }, [selectedSpanId, flattenedSpans, virtualizer]);
278
+
279
+ useEffect(() => {
280
+ const handleKeyDown = (e: KeyboardEvent) => {
281
+ const timelineElement = scrollRef.current?.parentElement;
282
+ if (!timelineElement?.contains(document.activeElement)) return;
283
+
284
+ switch (e.key) {
285
+ case "ArrowUp":
286
+ case "k":
287
+ case "K":
288
+ e.preventDefault();
289
+ handleNavigateUp();
290
+ break;
291
+ case "ArrowDown":
292
+ case "j":
293
+ case "J":
294
+ e.preventDefault();
295
+ handleNavigateDown();
296
+ break;
297
+ case "ArrowLeft":
298
+ e.preventDefault();
299
+ handleCollapseExpand(true);
300
+ break;
301
+ case "ArrowRight":
302
+ e.preventDefault();
303
+ handleCollapseExpand(false);
304
+ break;
305
+ case "Escape":
306
+ e.preventDefault();
307
+ handleDeselect();
308
+ break;
309
+ case "Enter": {
310
+ if (selectedSpanId) {
311
+ e.preventDefault();
312
+ const detailPane = document.querySelector(
313
+ '[role="complementary"][aria-label="Span details"]'
314
+ );
315
+ if (detailPane) {
316
+ detailPane.scrollIntoView({ behavior: "smooth", block: "start" });
317
+ (detailPane as HTMLElement).focus?.();
318
+ }
319
+ }
320
+ break;
321
+ }
322
+ case "e":
323
+ case "E":
324
+ if (e.ctrlKey && e.shiftKey) {
325
+ e.preventDefault();
326
+ handleExpandAll();
327
+ }
328
+ break;
329
+ case "c":
330
+ case "C":
331
+ if (e.ctrlKey && e.shiftKey) {
332
+ e.preventDefault();
333
+ handleCollapseAll();
334
+ } else if (!e.ctrlKey && !e.metaKey) {
335
+ e.preventDefault();
336
+ const selected = flattenedSpans.find(
337
+ (item) => item.span.spanId === selectedSpanId
338
+ );
339
+ if (selected) {
340
+ navigator.clipboard.writeText(selected.span.name).catch(() => {});
341
+ }
342
+ }
343
+ break;
344
+ }
345
+ };
346
+ window.addEventListener("keydown", handleKeyDown);
347
+ return () => window.removeEventListener("keydown", handleKeyDown);
348
+ }, [
349
+ handleNavigateUp,
350
+ handleNavigateDown,
351
+ handleCollapseExpand,
352
+ handleDeselect,
353
+ handleExpandAll,
354
+ handleCollapseAll,
355
+ selectedSpanId,
356
+ flattenedSpans,
357
+ ]);
358
+
359
+ if (isLoading) return <LoadingSkeleton />;
360
+
361
+ if (error) {
362
+ return (
363
+ <div className="flex items-center justify-center h-64 bg-background">
364
+ <div className="text-red-600 dark:text-red-400">
365
+ <div className="font-semibold">Error loading trace</div>
366
+ <div className="text-sm">{error.message}</div>
367
+ </div>
368
+ </div>
369
+ );
370
+ }
371
+
372
+ if (rows.length === 0 || !parsedTrace) {
373
+ return (
374
+ <div className="flex items-center justify-center h-64 bg-background">
375
+ <div className="text-muted-foreground">No trace data available</div>
376
+ </div>
377
+ );
378
+ }
379
+
380
+ const totalDurationMs = parsedTrace.maxTimeMs - parsedTrace.minTimeMs;
381
+ const selectedSpan =
382
+ selectedSpanId && flattenedSpans.length > 0
383
+ ? flattenedSpans.find((item) => item.span.spanId === selectedSpanId)?.span
384
+ : null;
385
+
386
+ return (
387
+ <div className="flex h-full bg-background">
388
+ <div className="flex flex-col flex-1 min-w-0">
389
+ <div
390
+ ref={announcementRef}
391
+ className="sr-only"
392
+ role="status"
393
+ aria-live="polite"
394
+ aria-atomic="true"
395
+ />
396
+ <TraceHeader trace={parsedTrace} />
397
+ <div
398
+ ref={scrollRef}
399
+ className="flex-1 overflow-auto outline-none"
400
+ role="tree"
401
+ aria-label="Trace timeline"
402
+ tabIndex={0}
403
+ >
404
+ <div
405
+ style={{
406
+ height: `${virtualizer.getTotalSize()}px`,
407
+ width: "100%",
408
+ position: "relative",
409
+ }}
410
+ >
411
+ {virtualizer.getVirtualItems().map((virtualItem) => {
412
+ const item = flattenedSpans[virtualItem.index];
413
+ if (!item) return null;
414
+
415
+ const { span, level } = item;
416
+ const isCollapsed = collapsedIds.has(span.spanId);
417
+ const isSelected = span.spanId === selectedSpanId;
418
+ const isHovered = span.spanId === hoveredSpanId;
419
+ const isParentOfHovered = hoveredSpanId
420
+ ? isSpanAncestorOf(span, hoveredSpanId, flattenedSpans)
421
+ : false;
422
+
423
+ const relativeStart = calculateRelativeTime(
424
+ span.startTimeUnixMs,
425
+ parsedTrace.minTimeMs,
426
+ parsedTrace.maxTimeMs
427
+ );
428
+ const relativeDuration = calculateRelativeDuration(
429
+ span.durationMs,
430
+ totalDurationMs
431
+ );
432
+
433
+ return (
434
+ <div
435
+ key={span.spanId}
436
+ style={{
437
+ position: "absolute",
438
+ top: 0,
439
+ left: 0,
440
+ width: "100%",
441
+ height: `${virtualItem.size}px`,
442
+ transform: `translateY(${virtualItem.start}px)`,
443
+ }}
444
+ >
445
+ <SpanRow
446
+ span={span}
447
+ level={level}
448
+ isCollapsed={isCollapsed}
449
+ isSelected={isSelected}
450
+ isHovered={isHovered}
451
+ isParentOfHovered={isParentOfHovered}
452
+ relativeStart={relativeStart}
453
+ relativeDuration={relativeDuration}
454
+ onClick={() => handleSpanClick(span)}
455
+ onToggleCollapse={() => handleToggleCollapse(span.spanId)}
456
+ onMouseEnter={() => setHoveredSpanId(span.spanId)}
457
+ onMouseLeave={() => setHoveredSpanId(null)}
458
+ />
459
+ </div>
460
+ );
461
+ })}
462
+ </div>
463
+ </div>
464
+ </div>
465
+
466
+ {selectedSpan && (
467
+ <div className="w-96 h-full flex-shrink-0">
468
+ <DetailPane
469
+ span={selectedSpan}
470
+ onClose={handleDeselect}
471
+ // TODO: wire up cross-trace navigation
472
+ onLinkClick={undefined}
473
+ />
474
+ </div>
475
+ )}
476
+ </div>
477
+ );
478
+ }
@@ -0,0 +1,16 @@
1
+ import type { ShortcutGroup } from "../../KeyboardShortcuts/types.js";
2
+
3
+ export const TRACE_VIEWER_SHORTCUTS: ShortcutGroup = {
4
+ name: "Trace Viewer",
5
+ shortcuts: [
6
+ { keys: ["↑/K"], description: "Previous span" },
7
+ { keys: ["↓/J"], description: "Next span" },
8
+ { keys: ["←"], description: "Collapse span" },
9
+ { keys: ["→"], description: "Expand span" },
10
+ { keys: ["Enter"], description: "Focus detail pane" },
11
+ { keys: ["C"], description: "Copy span name" },
12
+ { keys: ["Esc"], description: "Deselect span" },
13
+ { keys: ["Ctrl", "Shift", "E"], description: "Expand all" },
14
+ { keys: ["Ctrl", "Shift", "C"], description: "Collapse all" },
15
+ ],
16
+ };