@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.
- package/README.md +137 -0
- package/dist/index.cjs +5069 -3
- package/dist/index.d.cts +301 -3
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +302 -3
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +5010 -3
- package/dist/index.mjs.map +1 -1
- package/package.json +25 -7
- package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +113 -0
- package/src/components/KeyboardShortcuts/ShortcutsHelpDialog.tsx +82 -0
- package/src/components/KeyboardShortcuts/context.ts +23 -0
- package/src/components/KeyboardShortcuts/index.ts +8 -0
- package/src/components/KeyboardShortcuts/types.ts +11 -0
- package/src/components/dashboard/Badge/Badge.stories.tsx +29 -0
- package/src/components/dashboard/Badge/index.tsx +32 -0
- package/src/components/dashboard/Button/Button.stories.tsx +107 -0
- package/src/components/dashboard/Button/index.tsx +63 -0
- package/src/components/dashboard/Card/Card.stories.tsx +81 -0
- package/src/components/dashboard/Card/index.tsx +58 -0
- package/src/components/dashboard/Chart/Chart.stories.tsx +48 -0
- package/src/components/dashboard/Chart/index.tsx +74 -0
- package/src/components/dashboard/DatePicker/DatePicker.stories.tsx +33 -0
- package/src/components/dashboard/DatePicker/index.tsx +41 -0
- package/src/components/dashboard/Divider/Divider.stories.tsx +17 -0
- package/src/components/dashboard/Divider/index.tsx +49 -0
- package/src/components/dashboard/Empty/Empty.stories.tsx +48 -0
- package/src/components/dashboard/Empty/index.tsx +46 -0
- package/src/components/dashboard/Grid/Grid.stories.tsx +52 -0
- package/src/components/dashboard/Grid/index.tsx +26 -0
- package/src/components/dashboard/Heading/Heading.stories.tsx +25 -0
- package/src/components/dashboard/Heading/index.tsx +27 -0
- package/src/components/dashboard/List/List.stories.tsx +37 -0
- package/src/components/dashboard/List/index.tsx +24 -0
- package/src/components/dashboard/Metric/Metric.stories.tsx +65 -0
- package/src/components/dashboard/Metric/index.tsx +36 -0
- package/src/components/dashboard/Stack/Stack.stories.tsx +61 -0
- package/src/components/dashboard/Stack/index.tsx +33 -0
- package/src/components/dashboard/Table/Table.stories.tsx +38 -0
- package/src/components/dashboard/Table/index.tsx +104 -0
- package/src/components/dashboard/Text/Text.stories.tsx +53 -0
- package/src/components/dashboard/Text/index.tsx +18 -0
- package/src/components/dashboard/index.ts +46 -0
- package/src/components/index.ts +17 -0
- package/src/components/observability/LogTimeline/LogDetailPane/AttributesTab.tsx +56 -0
- package/src/components/observability/LogTimeline/LogDetailPane/JsonTreeView.tsx +139 -0
- package/src/components/observability/LogTimeline/LogDetailPane/index.tsx +271 -0
- package/src/components/observability/LogTimeline/LogFilter.stories.tsx +66 -0
- package/src/components/observability/LogTimeline/LogFilter.test.tsx +696 -0
- package/src/components/observability/LogTimeline/LogFilter.tsx +674 -0
- package/src/components/observability/LogTimeline/LogRow.tsx +174 -0
- package/src/components/observability/LogTimeline/LogTimeline.stories.tsx +154 -0
- package/src/components/observability/LogTimeline/index.tsx +542 -0
- package/src/components/observability/LogTimeline/shortcuts.ts +18 -0
- package/src/components/observability/MetricHistogram/MetricHistogram.stories.tsx +20 -0
- package/src/components/observability/MetricHistogram/index.tsx +303 -0
- package/src/components/observability/MetricStat/MetricStat.stories.tsx +30 -0
- package/src/components/observability/MetricStat/index.tsx +281 -0
- package/src/components/observability/MetricTable/MetricTable.stories.tsx +20 -0
- package/src/components/observability/MetricTable/index.tsx +194 -0
- package/src/components/observability/MetricTimeSeries/MetricTimeSeries.stories.tsx +28 -0
- package/src/components/observability/MetricTimeSeries/index.tsx +462 -0
- package/src/components/observability/RawDataTable/RawDataTable.stories.tsx +27 -0
- package/src/components/observability/RawDataTable/index.tsx +131 -0
- package/src/components/observability/ServiceList/ServiceList.stories.tsx +20 -0
- package/src/components/observability/ServiceList/index.tsx +60 -0
- package/src/components/observability/ServiceList/shortcuts.ts +6 -0
- package/src/components/observability/TabBar/TabBar.stories.tsx +34 -0
- package/src/components/observability/TabBar/index.tsx +46 -0
- package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +51 -0
- package/src/components/observability/TraceDetail/index.tsx +53 -0
- package/src/components/observability/TraceSearch/TraceSearch.stories.tsx +49 -0
- package/src/components/observability/TraceSearch/index.tsx +292 -0
- package/src/components/observability/TraceTimeline/DetailPane/AttributesTab.tsx +152 -0
- package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +128 -0
- package/src/components/observability/TraceTimeline/DetailPane/LinksTab.tsx +210 -0
- package/src/components/observability/TraceTimeline/DetailPane/index.tsx +174 -0
- package/src/components/observability/TraceTimeline/SpanRow.tsx +173 -0
- package/src/components/observability/TraceTimeline/TimelineBar.tsx +41 -0
- package/src/components/observability/TraceTimeline/Tooltip.tsx +42 -0
- package/src/components/observability/TraceTimeline/TraceHeader.tsx +88 -0
- package/src/components/observability/TraceTimeline/TraceTimeline.stories.tsx +25 -0
- package/src/components/observability/TraceTimeline/index.tsx +478 -0
- package/src/components/observability/TraceTimeline/shortcuts.ts +16 -0
- package/src/components/observability/__fixtures__/logs.ts +476 -0
- package/src/components/observability/__fixtures__/metrics.ts +216 -0
- package/src/components/observability/__fixtures__/raw-table.ts +204 -0
- package/src/components/observability/__fixtures__/services.ts +8 -0
- package/src/components/observability/__fixtures__/trace-summaries.ts +81 -0
- package/src/components/observability/__fixtures__/traces.ts +396 -0
- package/src/components/observability/index.ts +66 -0
- package/src/components/observability/renderers/OtelMetricDiscovery.tsx +77 -0
- package/src/components/observability/renderers/OtelMetricHistogram.tsx +29 -0
- package/src/components/observability/renderers/OtelMetricStat.tsx +44 -0
- package/src/components/observability/renderers/OtelMetricTable.tsx +29 -0
- package/src/components/observability/renderers/OtelMetricTimeSeries.tsx +30 -0
- package/src/components/observability/renderers/index.ts +5 -0
- package/src/components/observability/shared/LoadingSkeleton.tsx +43 -0
- package/src/components/observability/types.ts +113 -0
- package/src/components/observability/utils/attributes.ts +17 -0
- package/src/components/observability/utils/colors.ts +29 -0
- package/src/components/observability/utils/flatten-tree.ts +53 -0
- package/src/components/observability/utils/lttb.ts +121 -0
- package/src/components/observability/utils/time.ts +46 -0
- package/src/hooks/use-kopai-data.test.ts +296 -0
- package/src/hooks/use-kopai-data.ts +64 -0
- package/src/hooks/use-live-logs.test.ts +193 -0
- package/src/hooks/use-live-logs.ts +113 -0
- package/src/index.ts +15 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +33 -0
- package/src/lib/catalog.ts +165 -0
- package/src/lib/component-catalog.test.ts +357 -0
- package/src/lib/component-catalog.ts +171 -0
- package/src/lib/dashboard-datasource.ts +76 -0
- package/src/lib/generate-prompt-instructions.test.ts +27 -0
- package/src/lib/generate-prompt-instructions.ts +185 -0
- package/src/lib/log-buffer.test.ts +88 -0
- package/src/lib/log-buffer.ts +62 -0
- package/src/lib/observability-catalog.ts +143 -0
- package/src/lib/renderer.test.tsx +693 -0
- package/src/lib/renderer.tsx +276 -0
- package/src/pages/observability.tsx +825 -0
- package/src/providers/kopai-provider.tsx +51 -0
- package/src/styles/globals.css +46 -0
- 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
|
+
}
|