@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,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
|
+
};
|