@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,210 @@
1
+ import { useState } from "react";
2
+ import type { SpanNode } from "../../types.js";
3
+ import { formatAttributeValue } from "../../utils/attributes.js";
4
+
5
+ export interface LinksTabProps {
6
+ span: SpanNode;
7
+ onLinkClick?: (traceId: string, spanId: string) => void;
8
+ }
9
+
10
+ function truncateId(id: string): string {
11
+ return id.length > 8 ? `${id.substring(0, 8)}...` : id;
12
+ }
13
+
14
+ export function LinksTab({ span, onLinkClick }: LinksTabProps) {
15
+ const [expandedLinks, setExpandedLinks] = useState<Set<number>>(new Set());
16
+ const [copiedId, setCopiedId] = useState<string | null>(null);
17
+
18
+ const toggleLinkExpanded = (index: number) => {
19
+ setExpandedLinks((prev) => {
20
+ const next = new Set(prev);
21
+ if (next.has(index)) next.delete(index);
22
+ else next.add(index);
23
+ return next;
24
+ });
25
+ };
26
+
27
+ const copyToClipboard = async (text: string, type: string, index: number) => {
28
+ try {
29
+ await navigator.clipboard.writeText(text);
30
+ setCopiedId(`${type}-${index}-${text}`);
31
+ setTimeout(() => setCopiedId(null), 2000);
32
+ } catch (err) {
33
+ console.error("Failed to copy:", err);
34
+ }
35
+ };
36
+
37
+ if (!span.links || span.links.length === 0) {
38
+ return (
39
+ <div className="text-sm text-muted-foreground text-center py-8">
40
+ No links available
41
+ </div>
42
+ );
43
+ }
44
+
45
+ return (
46
+ <div className="space-y-3">
47
+ {span.links.map((link, index) => {
48
+ const isExpanded = expandedLinks.has(index);
49
+ const hasAttributes =
50
+ link.attributes && Object.keys(link.attributes).length > 0;
51
+
52
+ return (
53
+ <div
54
+ key={index}
55
+ className="border border-border rounded-lg overflow-hidden"
56
+ >
57
+ <div className="bg-muted p-3">
58
+ <div className="mb-2">
59
+ <div className="text-xs font-semibold text-muted-foreground mb-1">
60
+ Trace ID
61
+ </div>
62
+ <div className="flex items-center gap-2">
63
+ <code
64
+ className="text-xs font-mono text-foreground bg-background px-2 py-1 rounded border border-border flex-1 truncate"
65
+ title={link.traceId}
66
+ >
67
+ {truncateId(link.traceId)}
68
+ </code>
69
+ <button
70
+ onClick={() =>
71
+ copyToClipboard(link.traceId, "trace", index)
72
+ }
73
+ className="p-1 hover:bg-muted/80 rounded transition-colors"
74
+ aria-label="Copy trace ID"
75
+ >
76
+ <svg
77
+ className={`w-4 h-4 ${copiedId === `trace-${index}-${link.traceId}` ? "text-green-600" : "text-muted-foreground"}`}
78
+ fill="none"
79
+ stroke="currentColor"
80
+ viewBox="0 0 24 24"
81
+ >
82
+ {copiedId === `trace-${index}-${link.traceId}` ? (
83
+ <path
84
+ strokeLinecap="round"
85
+ strokeLinejoin="round"
86
+ strokeWidth={2}
87
+ d="M5 13l4 4L19 7"
88
+ />
89
+ ) : (
90
+ <path
91
+ strokeLinecap="round"
92
+ strokeLinejoin="round"
93
+ strokeWidth={2}
94
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
95
+ />
96
+ )}
97
+ </svg>
98
+ </button>
99
+ </div>
100
+ </div>
101
+
102
+ <div className="mb-2">
103
+ <div className="text-xs font-semibold text-muted-foreground mb-1">
104
+ Span ID
105
+ </div>
106
+ <div className="flex items-center gap-2">
107
+ <code
108
+ className="text-xs font-mono text-foreground bg-background px-2 py-1 rounded border border-border flex-1 truncate"
109
+ title={link.spanId}
110
+ >
111
+ {truncateId(link.spanId)}
112
+ </code>
113
+ <button
114
+ onClick={() => copyToClipboard(link.spanId, "span", index)}
115
+ className="p-1 hover:bg-muted/80 rounded transition-colors"
116
+ aria-label="Copy span ID"
117
+ >
118
+ <svg
119
+ className={`w-4 h-4 ${copiedId === `span-${index}-${link.spanId}` ? "text-green-600" : "text-muted-foreground"}`}
120
+ fill="none"
121
+ stroke="currentColor"
122
+ viewBox="0 0 24 24"
123
+ >
124
+ {copiedId === `span-${index}-${link.spanId}` ? (
125
+ <path
126
+ strokeLinecap="round"
127
+ strokeLinejoin="round"
128
+ strokeWidth={2}
129
+ d="M5 13l4 4L19 7"
130
+ />
131
+ ) : (
132
+ <path
133
+ strokeLinecap="round"
134
+ strokeLinejoin="round"
135
+ strokeWidth={2}
136
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
137
+ />
138
+ )}
139
+ </svg>
140
+ </button>
141
+ </div>
142
+ </div>
143
+
144
+ {onLinkClick && (
145
+ <button
146
+ onClick={() => onLinkClick(link.traceId, link.spanId)}
147
+ className="w-full mt-2 px-3 py-2 bg-primary text-primary-foreground text-sm font-medium rounded hover:bg-primary/90 transition-colors"
148
+ >
149
+ Navigate to Span
150
+ </button>
151
+ )}
152
+
153
+ {hasAttributes && (
154
+ <button
155
+ onClick={() => toggleLinkExpanded(index)}
156
+ className="w-full mt-2 px-3 py-1.5 text-xs text-foreground bg-background border border-border rounded hover:bg-muted transition-colors flex items-center justify-center gap-1"
157
+ aria-expanded={isExpanded}
158
+ >
159
+ <span>
160
+ {isExpanded ? "Hide" : "Show"} Attributes (
161
+ {Object.keys(link.attributes).length})
162
+ </span>
163
+ <svg
164
+ className={`w-3 h-3 transition-transform ${isExpanded ? "rotate-180" : ""}`}
165
+ fill="none"
166
+ stroke="currentColor"
167
+ viewBox="0 0 24 24"
168
+ >
169
+ <path
170
+ strokeLinecap="round"
171
+ strokeLinejoin="round"
172
+ strokeWidth={2}
173
+ d="M19 9l-7 7-7-7"
174
+ />
175
+ </svg>
176
+ </button>
177
+ )}
178
+ </div>
179
+
180
+ {hasAttributes && isExpanded && (
181
+ <div className="p-3 bg-background border-t border-border">
182
+ <div className="space-y-2">
183
+ {Object.entries(link.attributes).map(([key, value]) => (
184
+ <div
185
+ key={key}
186
+ className="grid grid-cols-[minmax(100px,1fr)_2fr] gap-3 text-xs"
187
+ >
188
+ <div className="font-mono font-medium text-foreground break-words">
189
+ {key}
190
+ </div>
191
+ <div className="text-foreground break-words">
192
+ {typeof value === "object" ? (
193
+ <pre className="text-xs bg-muted p-2 rounded border border-border overflow-x-auto">
194
+ {formatAttributeValue(value)}
195
+ </pre>
196
+ ) : (
197
+ <span>{formatAttributeValue(value)}</span>
198
+ )}
199
+ </div>
200
+ </div>
201
+ ))}
202
+ </div>
203
+ </div>
204
+ )}
205
+ </div>
206
+ );
207
+ })}
208
+ </div>
209
+ );
210
+ }
@@ -0,0 +1,174 @@
1
+ import { useState, useCallback } from "react";
2
+ import type { SpanNode } from "../../types.js";
3
+ import { AttributesTab } from "./AttributesTab.js";
4
+ import { EventsTab } from "./EventsTab.js";
5
+ import { LinksTab } from "./LinksTab.js";
6
+
7
+ export interface DetailPaneProps {
8
+ span: SpanNode;
9
+ onClose: () => void;
10
+ onLinkClick?: (traceId: string, spanId: string) => void;
11
+ initialTab?: "attributes" | "events" | "links";
12
+ }
13
+
14
+ type TabType = "attributes" | "events" | "links";
15
+
16
+ export function DetailPane({
17
+ span,
18
+ onClose,
19
+ onLinkClick,
20
+ initialTab = "attributes",
21
+ }: DetailPaneProps) {
22
+ const [activeTab, setActiveTab] = useState<TabType>(initialTab);
23
+ const [copiedId, setCopiedId] = useState(false);
24
+
25
+ const handleTabChange = useCallback((tab: TabType) => {
26
+ setActiveTab(tab);
27
+ }, []);
28
+
29
+ const handleCopySpanId = useCallback(async () => {
30
+ try {
31
+ await navigator.clipboard.writeText(span.spanId);
32
+ setCopiedId(true);
33
+ setTimeout(() => setCopiedId(false), 2000);
34
+ } catch (err) {
35
+ console.error("Failed to copy span ID:", err);
36
+ }
37
+ }, [span.spanId]);
38
+
39
+ const handleKeyDown = useCallback(
40
+ (e: React.KeyboardEvent) => {
41
+ if (e.key === "Escape") onClose();
42
+ },
43
+ [onClose]
44
+ );
45
+
46
+ return (
47
+ <div
48
+ className="flex flex-col h-full bg-background border-l border-border"
49
+ onKeyDown={handleKeyDown}
50
+ tabIndex={-1}
51
+ role="complementary"
52
+ aria-label="Span details"
53
+ >
54
+ {/* Header */}
55
+ <div className="p-4 border-b border-border">
56
+ <div className="flex items-center justify-between mb-3">
57
+ <h2 className="text-lg font-semibold text-foreground truncate">
58
+ Span Details
59
+ </h2>
60
+ <button
61
+ onClick={onClose}
62
+ className="p-1 hover:bg-muted rounded transition-colors"
63
+ aria-label="Close detail pane"
64
+ title="Close (Esc)"
65
+ >
66
+ <svg
67
+ className="w-5 h-5 text-muted-foreground"
68
+ fill="none"
69
+ stroke="currentColor"
70
+ viewBox="0 0 24 24"
71
+ >
72
+ <path
73
+ strokeLinecap="round"
74
+ strokeLinejoin="round"
75
+ strokeWidth={2}
76
+ d="M6 18L18 6M6 6l12 12"
77
+ />
78
+ </svg>
79
+ </button>
80
+ </div>
81
+ <div className="mb-2">
82
+ <div
83
+ className="text-sm font-medium text-foreground truncate"
84
+ title={span.name}
85
+ >
86
+ {span.name}
87
+ </div>
88
+ </div>
89
+ <div className="flex items-center gap-2">
90
+ <span className="text-xs text-muted-foreground">Span ID:</span>
91
+ <code
92
+ className="text-xs font-mono text-foreground bg-muted px-2 py-1 rounded flex-1 truncate"
93
+ title={span.spanId}
94
+ >
95
+ {span.spanId}
96
+ </code>
97
+ <button
98
+ onClick={handleCopySpanId}
99
+ className="p-1 hover:bg-muted rounded transition-colors"
100
+ aria-label="Copy span ID"
101
+ >
102
+ <svg
103
+ className={`w-4 h-4 ${copiedId ? "text-green-600 dark:text-green-400" : "text-muted-foreground"}`}
104
+ fill="none"
105
+ stroke="currentColor"
106
+ viewBox="0 0 24 24"
107
+ >
108
+ {copiedId ? (
109
+ <path
110
+ strokeLinecap="round"
111
+ strokeLinejoin="round"
112
+ strokeWidth={2}
113
+ d="M5 13l4 4L19 7"
114
+ />
115
+ ) : (
116
+ <path
117
+ strokeLinecap="round"
118
+ strokeLinejoin="round"
119
+ strokeWidth={2}
120
+ d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"
121
+ />
122
+ )}
123
+ </svg>
124
+ </button>
125
+ </div>
126
+ </div>
127
+
128
+ {/* Tabs */}
129
+ <div
130
+ className="flex border-b border-border"
131
+ role="tablist"
132
+ aria-label="Span detail tabs"
133
+ >
134
+ {(["attributes", "events", "links"] as const).map((tab) => {
135
+ const count =
136
+ tab === "attributes"
137
+ ? Object.keys(span.attributes).length
138
+ : tab === "events"
139
+ ? span.events.length
140
+ : span.links.length;
141
+ return (
142
+ <button
143
+ key={tab}
144
+ role="tab"
145
+ aria-selected={activeTab === tab}
146
+ onClick={() => handleTabChange(tab)}
147
+ className={`px-4 py-2 text-sm font-medium transition-colors ${
148
+ activeTab === tab
149
+ ? "text-blue-600 dark:text-blue-400 border-b-2 border-blue-600 dark:border-blue-400"
150
+ : "text-muted-foreground hover:text-foreground"
151
+ }`}
152
+ >
153
+ {tab.charAt(0).toUpperCase() + tab.slice(1)}
154
+ {count > 0 && (
155
+ <span className="ml-1 text-xs text-muted-foreground">
156
+ ({count})
157
+ </span>
158
+ )}
159
+ </button>
160
+ );
161
+ })}
162
+ </div>
163
+
164
+ {/* Content */}
165
+ <div className="flex-1 overflow-auto p-4">
166
+ {activeTab === "attributes" && <AttributesTab span={span} />}
167
+ {activeTab === "events" && <EventsTab span={span} />}
168
+ {activeTab === "links" && (
169
+ <LinksTab span={span} onLinkClick={onLinkClick} />
170
+ )}
171
+ </div>
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,173 @@
1
+ import { memo } from "react";
2
+ import type { SpanNode } from "../types.js";
3
+ import { TimelineBar } from "./TimelineBar.js";
4
+ import { formatDuration } from "../utils/time.js";
5
+
6
+ export interface SpanRowProps {
7
+ span: SpanNode;
8
+ level: number;
9
+ isCollapsed: boolean;
10
+ isSelected: boolean;
11
+ isHovered?: boolean;
12
+ isParentOfHovered?: boolean;
13
+ relativeStart: number;
14
+ relativeDuration: number;
15
+ onClick: () => void;
16
+ onToggleCollapse: () => void;
17
+ onMouseEnter?: () => void;
18
+ onMouseLeave?: () => void;
19
+ }
20
+
21
+ function getHttpContext(span: SpanNode): string | null {
22
+ const attrs = span.attributes;
23
+ const method = attrs["http.method"];
24
+ const url = attrs["http.url"] || attrs["http.target"];
25
+ const statusCode = attrs["http.status_code"];
26
+
27
+ if (!method && !url) return null;
28
+
29
+ const parts: string[] = [];
30
+ if (method) parts.push(String(method));
31
+ if (url) parts.push(String(url));
32
+ if (statusCode) parts.push(`[${statusCode}]`);
33
+
34
+ return parts.join(" ");
35
+ }
36
+
37
+ export const SpanRow = memo(function SpanRow({
38
+ span,
39
+ level,
40
+ isCollapsed,
41
+ isSelected,
42
+ isParentOfHovered = false,
43
+ relativeStart,
44
+ relativeDuration,
45
+ onClick,
46
+ onToggleCollapse,
47
+ onMouseEnter,
48
+ onMouseLeave,
49
+ }: SpanRowProps) {
50
+ const hasChildren = span.children.length > 0;
51
+ const isError = span.status === "ERROR";
52
+ const httpContext = getHttpContext(span);
53
+
54
+ return (
55
+ <div
56
+ className={`flex h-8 border-b border-border hover:bg-muted cursor-pointer ${
57
+ isSelected
58
+ ? "bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-100 dark:hover:bg-blue-900/30"
59
+ : ""
60
+ }`}
61
+ onClick={onClick}
62
+ onMouseEnter={onMouseEnter}
63
+ onMouseLeave={onMouseLeave}
64
+ role="treeitem"
65
+ aria-expanded={hasChildren ? !isCollapsed : undefined}
66
+ aria-selected={isSelected}
67
+ aria-label={`${span.name}, ${span.serviceName}, ${formatDuration(span.durationMs)}${isError ? ", error" : ""}`}
68
+ aria-level={level + 1}
69
+ >
70
+ {/* Left side: Service name + span name with indentation */}
71
+ <div className="flex items-center min-w-0 flex-shrink-0 w-96 px-2 relative z-10">
72
+ {Array.from({ length: level }).map((_, i) => (
73
+ <div
74
+ key={i}
75
+ className={`w-4 h-full border-l flex-shrink-0 ${
76
+ isParentOfHovered ? "border-blue-500 border-l-2" : "border-border"
77
+ }`}
78
+ />
79
+ ))}
80
+
81
+ {hasChildren ? (
82
+ <button
83
+ className="w-4 h-4 flex items-center justify-center flex-shrink-0 text-muted-foreground hover:text-foreground"
84
+ onClick={(e) => {
85
+ e.stopPropagation();
86
+ onToggleCollapse();
87
+ }}
88
+ aria-label={isCollapsed ? "Expand" : "Collapse"}
89
+ >
90
+ {isCollapsed ? (
91
+ <svg
92
+ className="w-3 h-3"
93
+ fill="none"
94
+ stroke="currentColor"
95
+ viewBox="0 0 24 24"
96
+ >
97
+ <path
98
+ strokeLinecap="round"
99
+ strokeLinejoin="round"
100
+ strokeWidth={2}
101
+ d="M9 5l7 7-7 7"
102
+ />
103
+ </svg>
104
+ ) : (
105
+ <svg
106
+ className="w-3 h-3"
107
+ fill="none"
108
+ stroke="currentColor"
109
+ viewBox="0 0 24 24"
110
+ >
111
+ <path
112
+ strokeLinecap="round"
113
+ strokeLinejoin="round"
114
+ strokeWidth={2}
115
+ d="M19 9l-7 7-7-7"
116
+ />
117
+ </svg>
118
+ )}
119
+ </button>
120
+ ) : (
121
+ <div className="w-4 flex-shrink-0" />
122
+ )}
123
+
124
+ {isError && (
125
+ <svg
126
+ className="w-4 h-4 text-red-500 flex-shrink-0 mr-1"
127
+ fill="currentColor"
128
+ viewBox="0 0 20 20"
129
+ >
130
+ <path
131
+ fillRule="evenodd"
132
+ d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
133
+ clipRule="evenodd"
134
+ />
135
+ </svg>
136
+ )}
137
+
138
+ <span className="text-xs text-muted-foreground flex-shrink-0 mr-2">
139
+ {span.serviceName}
140
+ </span>
141
+
142
+ <span className="text-sm font-medium truncate flex-1 min-w-0 text-foreground">
143
+ {span.name}
144
+ </span>
145
+
146
+ {hasChildren && (
147
+ <span className="text-xs text-muted-foreground flex-shrink-0 ml-1">
148
+ ({span.children.length})
149
+ </span>
150
+ )}
151
+
152
+ {httpContext && (
153
+ <span className="text-xs text-muted-foreground truncate ml-2 flex-shrink-0 max-w-xs">
154
+ {httpContext}
155
+ </span>
156
+ )}
157
+
158
+ <span className="text-xs text-muted-foreground flex-shrink-0 ml-2">
159
+ {formatDuration(span.durationMs)}
160
+ </span>
161
+ </div>
162
+
163
+ {/* Right side: Timeline bar */}
164
+ <div className="flex-1 min-w-0 px-2">
165
+ <TimelineBar
166
+ span={span}
167
+ relativeStart={relativeStart}
168
+ relativeDuration={relativeDuration}
169
+ />
170
+ </div>
171
+ </div>
172
+ );
173
+ });
@@ -0,0 +1,41 @@
1
+ import type { SpanNode } from "../types.js";
2
+ import { getSpanBarColor } from "../utils/colors.js";
3
+ import { formatDuration } from "../utils/time.js";
4
+ import { Tooltip } from "./Tooltip.js";
5
+
6
+ export interface TimelineBarProps {
7
+ span: SpanNode;
8
+ relativeStart: number;
9
+ relativeDuration: number;
10
+ }
11
+
12
+ export function TimelineBar({
13
+ span,
14
+ relativeStart,
15
+ relativeDuration,
16
+ }: TimelineBarProps) {
17
+ const isError = span.status === "ERROR";
18
+ const barColor = getSpanBarColor(span.serviceName, isError);
19
+
20
+ const leftPercent = relativeStart * 100;
21
+ const widthPercent = Math.max(0.2, relativeDuration * 100);
22
+
23
+ const tooltipText = `${span.name}\n${formatDuration(span.durationMs)}\nStatus: ${isError ? "ERROR" : "OK"}`;
24
+
25
+ return (
26
+ <div className="relative h-full">
27
+ <Tooltip content={tooltipText}>
28
+ <div className="absolute inset-0">
29
+ <div
30
+ className="absolute top-1/2 -translate-y-1/2 h-2 rounded-sm cursor-pointer hover:opacity-80 transition-opacity"
31
+ style={{
32
+ left: `${leftPercent}%`,
33
+ width: `max(2px, ${widthPercent}%)`,
34
+ backgroundColor: barColor,
35
+ }}
36
+ />
37
+ </div>
38
+ </Tooltip>
39
+ </div>
40
+ );
41
+ }
@@ -0,0 +1,42 @@
1
+ import { useState } from "react";
2
+ import { createPortal } from "react-dom";
3
+
4
+ export interface TooltipProps {
5
+ content: string;
6
+ children: React.ReactNode;
7
+ }
8
+
9
+ export function Tooltip({ content, children }: TooltipProps) {
10
+ const [isVisible, setIsVisible] = useState(false);
11
+ const [position, setPosition] = useState({ x: 0, y: 0 });
12
+
13
+ const handleMouseMove = (e: React.MouseEvent) => {
14
+ setPosition({ x: e.clientX + 5, y: e.clientY + 5 });
15
+ };
16
+
17
+ return (
18
+ <>
19
+ <div
20
+ onMouseEnter={() => setIsVisible(true)}
21
+ onMouseLeave={() => setIsVisible(false)}
22
+ onMouseMove={handleMouseMove}
23
+ className="inline-block"
24
+ >
25
+ {children}
26
+ </div>
27
+ {isVisible &&
28
+ createPortal(
29
+ <div
30
+ className="fixed z-50 px-2 py-1 text-xs text-primary-foreground bg-primary rounded shadow-lg pointer-events-none whitespace-pre-line"
31
+ style={{
32
+ left: `${position.x}px`,
33
+ top: `${position.y}px`,
34
+ }}
35
+ >
36
+ {content}
37
+ </div>,
38
+ document.body
39
+ )}
40
+ </>
41
+ );
42
+ }