@kopai/ui 0.8.0 → 0.9.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/dist/index.cjs +2427 -1139
- package/dist/index.d.cts +36 -7
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.mts +36 -7
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2376 -1082
- package/dist/index.mjs.map +1 -1
- package/package.json +13 -13
- package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +7 -7
- package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +5 -0
- package/src/components/observability/LogTimeline/LogFilter.tsx +5 -1
- package/src/components/observability/LogTimeline/index.tsx +6 -2
- package/src/components/observability/MetricHistogram/index.tsx +20 -19
- package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
- package/src/components/observability/ServiceList/shortcuts.ts +1 -1
- package/src/components/observability/TraceComparison/index.tsx +332 -0
- package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
- package/src/components/observability/TraceDetail/index.tsx +4 -3
- package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
- package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
- package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
- package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
- package/src/components/observability/TraceSearch/index.tsx +211 -218
- package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
- package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
- package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
- package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
- package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
- package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
- package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
- package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
- package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
- package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
- package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
- package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
- package/src/components/observability/TraceTimeline/index.tsx +254 -110
- package/src/components/observability/index.ts +15 -0
- package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
- package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
- package/src/components/observability/utils/flatten-tree.ts +15 -0
- package/src/components/observability/utils/time.ts +9 -0
- package/src/hooks/use-kopai-data.test.ts +3 -0
- package/src/hooks/use-kopai-data.ts +11 -0
- package/src/hooks/use-live-logs.test.ts +3 -0
- package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
- package/src/lib/component-catalog.ts +15 -0
- package/src/pages/observability.test.tsx +5 -0
- package/src/pages/observability.tsx +314 -235
- package/src/providers/kopai-provider.tsx +3 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kopai/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"author": "Vladimir Adamic",
|
|
6
6
|
"repository": {
|
|
@@ -31,32 +31,32 @@
|
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
33
|
"@tanstack/react-query": "^5",
|
|
34
|
-
"@tanstack/react-virtual": "^3.13.
|
|
35
|
-
"recharts": "^3.
|
|
36
|
-
"@kopai/core": "0.
|
|
37
|
-
"@kopai/sdk": "0.
|
|
34
|
+
"@tanstack/react-virtual": "^3.13.22",
|
|
35
|
+
"recharts": "^3.8.0",
|
|
36
|
+
"@kopai/core": "0.8.0",
|
|
37
|
+
"@kopai/sdk": "0.6.0"
|
|
38
38
|
},
|
|
39
39
|
"peerDependencies": {
|
|
40
40
|
"react": "^19.2.4",
|
|
41
41
|
"react-dom": "^19.2.4"
|
|
42
42
|
},
|
|
43
43
|
"devDependencies": {
|
|
44
|
-
"@storybook/addon-docs": "^10.2.
|
|
45
|
-
"@storybook/react": "^10.2.
|
|
46
|
-
"@storybook/react-vite": "^10.2.
|
|
44
|
+
"@storybook/addon-docs": "^10.2.19",
|
|
45
|
+
"@storybook/react": "^10.2.19",
|
|
46
|
+
"@storybook/react-vite": "^10.2.19",
|
|
47
47
|
"@testing-library/react": "^16.3.0",
|
|
48
48
|
"@types/react": "^19.2.14",
|
|
49
49
|
"@types/react-dom": "^19.1.0",
|
|
50
|
-
"@vitejs/plugin-react": "^
|
|
50
|
+
"@vitejs/plugin-react": "^6.0.1",
|
|
51
51
|
"@tailwindcss/postcss": "^4.2.1",
|
|
52
52
|
"jsdom": "^28.1.0",
|
|
53
|
-
"postcss": "^8.5.
|
|
53
|
+
"postcss": "^8.5.8",
|
|
54
54
|
"react": "^19.2.4",
|
|
55
55
|
"react-dom": "^19.2.4",
|
|
56
|
-
"storybook": "^10.2.
|
|
56
|
+
"storybook": "^10.2.19",
|
|
57
57
|
"tailwindcss": "^4.2.1",
|
|
58
|
-
"tsdown": "^0.
|
|
59
|
-
"vite": "^
|
|
58
|
+
"tsdown": "^0.21.2",
|
|
59
|
+
"vite": "^8.0.0",
|
|
60
60
|
"@kopai/tsconfig": "0.2.0"
|
|
61
61
|
},
|
|
62
62
|
"scripts": {
|
|
@@ -8,7 +8,7 @@ const GENERAL_GROUP: ShortcutGroup = {
|
|
|
8
8
|
name: "General",
|
|
9
9
|
shortcuts: [
|
|
10
10
|
{ keys: ["Shift", "?"], description: "Toggle shortcuts help" },
|
|
11
|
-
{ keys: ["Shift", "
|
|
11
|
+
{ keys: ["Shift", "T"], description: "Traces tab" },
|
|
12
12
|
{ keys: ["Shift", "L"], description: "Logs tab" },
|
|
13
13
|
{ keys: ["Shift", "M"], description: "Metrics tab" },
|
|
14
14
|
],
|
|
@@ -48,12 +48,12 @@ export function KeyboardShortcutsProvider({
|
|
|
48
48
|
|
|
49
49
|
useEffect(() => {
|
|
50
50
|
function handleKeyDown(e: KeyboardEvent) {
|
|
51
|
-
|
|
51
|
+
if (!(e.target instanceof HTMLElement)) return;
|
|
52
52
|
if (
|
|
53
|
-
target.tagName === "INPUT" ||
|
|
54
|
-
target.tagName === "TEXTAREA" ||
|
|
55
|
-
target.tagName === "SELECT" ||
|
|
56
|
-
target.isContentEditable
|
|
53
|
+
e.target.tagName === "INPUT" ||
|
|
54
|
+
e.target.tagName === "TEXTAREA" ||
|
|
55
|
+
e.target.tagName === "SELECT" ||
|
|
56
|
+
e.target.isContentEditable
|
|
57
57
|
) {
|
|
58
58
|
return;
|
|
59
59
|
}
|
|
@@ -70,7 +70,7 @@ export function KeyboardShortcutsProvider({
|
|
|
70
70
|
return;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
if (e.shiftKey && e.key === "
|
|
73
|
+
if (e.shiftKey && e.key === "T") {
|
|
74
74
|
e.preventDefault();
|
|
75
75
|
onNavigateServices();
|
|
76
76
|
return;
|
|
@@ -28,6 +28,11 @@ function createMockClient(): MockClient {
|
|
|
28
28
|
.fn()
|
|
29
29
|
.mockResolvedValue({ data: [], nextCursor: null }),
|
|
30
30
|
searchDashboards: vi.fn().mockReturnValue((async function* () {})()),
|
|
31
|
+
getServices: vi.fn().mockResolvedValue({ services: [] }),
|
|
32
|
+
getOperations: vi.fn().mockResolvedValue({ operations: [] }),
|
|
33
|
+
searchTraceSummariesPage: vi
|
|
34
|
+
.fn()
|
|
35
|
+
.mockResolvedValue({ data: [], nextCursor: null }),
|
|
31
36
|
};
|
|
32
37
|
}
|
|
33
38
|
|
|
@@ -130,7 +130,11 @@ function MultiSelect({
|
|
|
130
130
|
useEffect(() => {
|
|
131
131
|
if (!dropOpen) return;
|
|
132
132
|
const handler = (e: MouseEvent) => {
|
|
133
|
-
if (
|
|
133
|
+
if (
|
|
134
|
+
ref.current &&
|
|
135
|
+
e.target instanceof Node &&
|
|
136
|
+
!ref.current.contains(e.target)
|
|
137
|
+
) {
|
|
134
138
|
setDropOpen(false);
|
|
135
139
|
}
|
|
136
140
|
};
|
|
@@ -269,8 +269,12 @@ export function LogTimeline({
|
|
|
269
269
|
e.target instanceof HTMLInputElement ||
|
|
270
270
|
e.target instanceof HTMLTextAreaElement ||
|
|
271
271
|
e.target instanceof HTMLSelectElement;
|
|
272
|
-
if (
|
|
273
|
-
|
|
272
|
+
if (
|
|
273
|
+
isFormField &&
|
|
274
|
+
e.key === "Escape" &&
|
|
275
|
+
e.target instanceof HTMLElement
|
|
276
|
+
) {
|
|
277
|
+
e.target.blur();
|
|
274
278
|
return;
|
|
275
279
|
}
|
|
276
280
|
if (isFormField) return;
|
|
@@ -13,9 +13,11 @@ import {
|
|
|
13
13
|
Legend,
|
|
14
14
|
ResponsiveContainer,
|
|
15
15
|
Cell,
|
|
16
|
+
type TooltipPayload,
|
|
16
17
|
} from "recharts";
|
|
17
18
|
import type { denormalizedSignals } from "@kopai/core";
|
|
18
19
|
import { formatSeriesLabel } from "../utils/attributes.js";
|
|
20
|
+
import { TooltipEntryList } from "../shared/TooltipEntryList.js";
|
|
19
21
|
import {
|
|
20
22
|
resolveUnitScale,
|
|
21
23
|
formatDisplayValue,
|
|
@@ -57,6 +59,12 @@ interface BucketData {
|
|
|
57
59
|
[seriesKey: string]: number | string;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
function isBucketData(v: unknown): v is BucketData {
|
|
63
|
+
return (
|
|
64
|
+
typeof v === "object" && v !== null && "bucket" in v && "lowerBound" in v
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
60
68
|
const defaultFormatBucketLabel = (
|
|
61
69
|
bound: number,
|
|
62
70
|
index: number,
|
|
@@ -125,7 +133,8 @@ function buildHistogramData(
|
|
|
125
133
|
};
|
|
126
134
|
buckets.push(bucket);
|
|
127
135
|
}
|
|
128
|
-
|
|
136
|
+
const prev = bucket[seriesName];
|
|
137
|
+
bucket[seriesName] = (typeof prev === "number" ? prev : 0) + count;
|
|
129
138
|
}
|
|
130
139
|
}
|
|
131
140
|
|
|
@@ -304,37 +313,29 @@ function HistogramTooltip({
|
|
|
304
313
|
displayLabelMap,
|
|
305
314
|
}: {
|
|
306
315
|
active?: boolean;
|
|
307
|
-
payload?:
|
|
308
|
-
dataKey: string;
|
|
309
|
-
value: number;
|
|
310
|
-
color: string;
|
|
311
|
-
payload: BucketData;
|
|
312
|
-
}[];
|
|
316
|
+
payload?: TooltipPayload;
|
|
313
317
|
formatValue: (val: number) => string;
|
|
314
318
|
boundsScale: ResolvedScale | null;
|
|
315
319
|
displayLabelMap: Map<string, string>;
|
|
316
320
|
}) {
|
|
317
321
|
if (!active || !payload?.length) return null;
|
|
318
|
-
const
|
|
319
|
-
if (!
|
|
322
|
+
const raw = payload[0]?.payload;
|
|
323
|
+
if (!isBucketData(raw)) return null;
|
|
320
324
|
|
|
321
325
|
const boundsLabel = boundsScale
|
|
322
|
-
? `${formatDisplayValue(
|
|
323
|
-
:
|
|
326
|
+
? `${formatDisplayValue(raw.lowerBound, boundsScale)} – ${raw.upperBound === Infinity ? "∞" : formatDisplayValue(raw.upperBound, boundsScale)}`
|
|
327
|
+
: raw.bucket;
|
|
324
328
|
|
|
325
329
|
return (
|
|
326
330
|
<div className="bg-background border border-gray-700 rounded-lg p-3 shadow-lg">
|
|
327
331
|
<p className="text-gray-300 text-sm font-medium mb-2">
|
|
328
332
|
Bucket: {boundsLabel}
|
|
329
333
|
</p>
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
{formatValue(entry.value)}
|
|
336
|
-
</p>
|
|
337
|
-
))}
|
|
334
|
+
<TooltipEntryList
|
|
335
|
+
payload={payload}
|
|
336
|
+
displayLabelMap={displayLabelMap}
|
|
337
|
+
formatValue={formatValue}
|
|
338
|
+
/>
|
|
338
339
|
</div>
|
|
339
340
|
);
|
|
340
341
|
}
|
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
ResponsiveContainer,
|
|
15
15
|
Brush,
|
|
16
16
|
ReferenceLine,
|
|
17
|
+
type TooltipPayload,
|
|
17
18
|
} from "recharts";
|
|
18
19
|
import type { denormalizedSignals } from "@kopai/core";
|
|
19
20
|
import type {
|
|
@@ -23,6 +24,7 @@ import type {
|
|
|
23
24
|
} from "../types.js";
|
|
24
25
|
import { downsampleLTTB, type LTTBPoint } from "../utils/lttb.js";
|
|
25
26
|
import { formatSeriesLabel } from "../utils/attributes.js";
|
|
27
|
+
import { TooltipEntryList } from "../shared/TooltipEntryList.js";
|
|
26
28
|
import {
|
|
27
29
|
resolveUnitScale,
|
|
28
30
|
formatTickValue,
|
|
@@ -461,7 +463,7 @@ function CustomTooltip({
|
|
|
461
463
|
displayLabelMap,
|
|
462
464
|
}: {
|
|
463
465
|
active?: boolean;
|
|
464
|
-
payload?:
|
|
466
|
+
payload?: TooltipPayload;
|
|
465
467
|
label?: string | number;
|
|
466
468
|
formatTime: (ts: number) => string;
|
|
467
469
|
formatValue: (val: number) => string;
|
|
@@ -472,14 +474,11 @@ function CustomTooltip({
|
|
|
472
474
|
return (
|
|
473
475
|
<div className="bg-background border border-gray-700 rounded-lg p-3 shadow-lg">
|
|
474
476
|
<p className="text-gray-400 text-xs mb-2">{formatTime(ts)}</p>
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
{formatValue(entry.value)}
|
|
481
|
-
</p>
|
|
482
|
-
))}
|
|
477
|
+
<TooltipEntryList
|
|
478
|
+
payload={payload}
|
|
479
|
+
displayLabelMap={displayLabelMap}
|
|
480
|
+
formatValue={formatValue}
|
|
481
|
+
/>
|
|
483
482
|
</div>
|
|
484
483
|
);
|
|
485
484
|
}
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import type { denormalizedSignals } from "@kopai/core";
|
|
3
|
+
import type { DataSource } from "../../../lib/component-catalog.js";
|
|
4
|
+
import { useKopaiData } from "../../../hooks/use-kopai-data.js";
|
|
5
|
+
import { TraceTimeline } from "../TraceTimeline/index.js";
|
|
6
|
+
import { formatDuration } from "../utils/time.js";
|
|
7
|
+
|
|
8
|
+
type OtelTracesRow = denormalizedSignals.OtelTracesRow;
|
|
9
|
+
|
|
10
|
+
export interface TraceComparisonProps {
|
|
11
|
+
traceIdA: string;
|
|
12
|
+
traceIdB: string;
|
|
13
|
+
onBack: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface DiffRow {
|
|
17
|
+
serviceName: string;
|
|
18
|
+
spanName: string;
|
|
19
|
+
countA: number;
|
|
20
|
+
countB: number;
|
|
21
|
+
avgDurationA: number;
|
|
22
|
+
avgDurationB: number;
|
|
23
|
+
deltaMs: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function computeTraceStats(rows: OtelTracesRow[]) {
|
|
27
|
+
if (rows.length === 0) return { durationMs: 0, spanCount: 0 };
|
|
28
|
+
let minTs = Infinity;
|
|
29
|
+
let maxEnd = -Infinity;
|
|
30
|
+
for (const row of rows) {
|
|
31
|
+
const startMs = parseInt(row.Timestamp, 10) / 1e6;
|
|
32
|
+
const durNs = row.Duration ? parseInt(row.Duration, 10) : 0;
|
|
33
|
+
const endMs = startMs + durNs / 1e6;
|
|
34
|
+
minTs = Math.min(minTs, startMs);
|
|
35
|
+
maxEnd = Math.max(maxEnd, endMs);
|
|
36
|
+
}
|
|
37
|
+
return { durationMs: maxEnd - minTs, spanCount: rows.length };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function collectSignatures(
|
|
41
|
+
rows: OtelTracesRow[]
|
|
42
|
+
): Map<string, { count: number; totalDurationMs: number }> {
|
|
43
|
+
const map = new Map<string, { count: number; totalDurationMs: number }>();
|
|
44
|
+
for (const row of rows) {
|
|
45
|
+
const key = `${row.ServiceName ?? "unknown"}::${row.SpanName ?? ""}`;
|
|
46
|
+
const durNs = row.Duration ? parseInt(row.Duration, 10) : 0;
|
|
47
|
+
const durMs = durNs / 1e6;
|
|
48
|
+
const existing = map.get(key);
|
|
49
|
+
if (existing) {
|
|
50
|
+
existing.count++;
|
|
51
|
+
existing.totalDurationMs += durMs;
|
|
52
|
+
} else {
|
|
53
|
+
map.set(key, { count: 1, totalDurationMs: durMs });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return map;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function computeDiff(
|
|
60
|
+
rowsA: OtelTracesRow[],
|
|
61
|
+
rowsB: OtelTracesRow[]
|
|
62
|
+
): DiffRow[] {
|
|
63
|
+
const sigA = collectSignatures(rowsA);
|
|
64
|
+
const sigB = collectSignatures(rowsB);
|
|
65
|
+
const allKeys = new Set([...sigA.keys(), ...sigB.keys()]);
|
|
66
|
+
const result: DiffRow[] = [];
|
|
67
|
+
|
|
68
|
+
for (const key of allKeys) {
|
|
69
|
+
const [serviceName = "unknown", spanName = ""] = key.split("::");
|
|
70
|
+
const a = sigA.get(key);
|
|
71
|
+
const b = sigB.get(key);
|
|
72
|
+
const countA = a?.count ?? 0;
|
|
73
|
+
const countB = b?.count ?? 0;
|
|
74
|
+
const avgA = a ? a.totalDurationMs / a.count : 0;
|
|
75
|
+
const avgB = b ? b.totalDurationMs / b.count : 0;
|
|
76
|
+
result.push({
|
|
77
|
+
serviceName,
|
|
78
|
+
spanName,
|
|
79
|
+
countA,
|
|
80
|
+
countB,
|
|
81
|
+
avgDurationA: avgA,
|
|
82
|
+
avgDurationB: avgB,
|
|
83
|
+
deltaMs: avgB - avgA,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Sort: spans only in A first, then only in B, then shared (by absolute delta desc)
|
|
88
|
+
return result.sort((a, b) => {
|
|
89
|
+
const aShared = a.countA > 0 && a.countB > 0;
|
|
90
|
+
const bShared = b.countA > 0 && b.countB > 0;
|
|
91
|
+
if (aShared !== bShared) return aShared ? 1 : -1;
|
|
92
|
+
return Math.abs(b.deltaMs) - Math.abs(a.deltaMs);
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function formatDelta(ms: number): string {
|
|
97
|
+
const sign = ms > 0 ? "+" : "";
|
|
98
|
+
return `${sign}${formatDuration(ms)}`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function TraceComparison({
|
|
102
|
+
traceIdA,
|
|
103
|
+
traceIdB,
|
|
104
|
+
onBack,
|
|
105
|
+
}: TraceComparisonProps) {
|
|
106
|
+
const dsA = useMemo<DataSource>(
|
|
107
|
+
() => ({ method: "getTrace", params: { traceId: traceIdA } }),
|
|
108
|
+
[traceIdA]
|
|
109
|
+
);
|
|
110
|
+
const dsB = useMemo<DataSource>(
|
|
111
|
+
() => ({ method: "getTrace", params: { traceId: traceIdB } }),
|
|
112
|
+
[traceIdB]
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
const {
|
|
116
|
+
data: rowsA,
|
|
117
|
+
loading: loadingA,
|
|
118
|
+
error: errorA,
|
|
119
|
+
} = useKopaiData<OtelTracesRow[]>(dsA);
|
|
120
|
+
const {
|
|
121
|
+
data: rowsB,
|
|
122
|
+
loading: loadingB,
|
|
123
|
+
error: errorB,
|
|
124
|
+
} = useKopaiData<OtelTracesRow[]>(dsB);
|
|
125
|
+
|
|
126
|
+
const statsA = useMemo(() => computeTraceStats(rowsA ?? []), [rowsA]);
|
|
127
|
+
const statsB = useMemo(() => computeTraceStats(rowsB ?? []), [rowsB]);
|
|
128
|
+
const diff = useMemo(
|
|
129
|
+
() => computeDiff(rowsA ?? [], rowsB ?? []),
|
|
130
|
+
[rowsA, rowsB]
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const durationDelta = statsB.durationMs - statsA.durationMs;
|
|
134
|
+
const spanDelta = statsB.spanCount - statsA.spanCount;
|
|
135
|
+
const isLoading = loadingA || loadingB;
|
|
136
|
+
|
|
137
|
+
return (
|
|
138
|
+
<div className="flex flex-col gap-4">
|
|
139
|
+
{/* Header */}
|
|
140
|
+
<div className="flex items-center justify-between bg-background border border-border rounded-lg p-4">
|
|
141
|
+
<div className="flex items-center gap-4">
|
|
142
|
+
<button
|
|
143
|
+
onClick={onBack}
|
|
144
|
+
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
|
|
145
|
+
>
|
|
146
|
+
← Back
|
|
147
|
+
</button>
|
|
148
|
+
<div className="flex items-center gap-6 text-sm">
|
|
149
|
+
<div>
|
|
150
|
+
<span className="text-muted-foreground mr-1">A:</span>
|
|
151
|
+
<span className="font-mono text-xs text-foreground">
|
|
152
|
+
{traceIdA.slice(0, 16)}...
|
|
153
|
+
</span>
|
|
154
|
+
</div>
|
|
155
|
+
<div>
|
|
156
|
+
<span className="text-muted-foreground mr-1">B:</span>
|
|
157
|
+
<span className="font-mono text-xs text-foreground">
|
|
158
|
+
{traceIdB.slice(0, 16)}...
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
</div>
|
|
163
|
+
{!isLoading && (
|
|
164
|
+
<div className="flex items-center gap-6 text-sm">
|
|
165
|
+
<div>
|
|
166
|
+
<span className="text-muted-foreground mr-1">
|
|
167
|
+
Duration delta:
|
|
168
|
+
</span>
|
|
169
|
+
<span
|
|
170
|
+
className={
|
|
171
|
+
durationDelta > 0
|
|
172
|
+
? "text-red-400"
|
|
173
|
+
: durationDelta < 0
|
|
174
|
+
? "text-green-400"
|
|
175
|
+
: "text-foreground"
|
|
176
|
+
}
|
|
177
|
+
>
|
|
178
|
+
{formatDelta(durationDelta)}
|
|
179
|
+
</span>
|
|
180
|
+
</div>
|
|
181
|
+
<div>
|
|
182
|
+
<span className="text-muted-foreground mr-1">
|
|
183
|
+
Span count delta:
|
|
184
|
+
</span>
|
|
185
|
+
<span
|
|
186
|
+
className={
|
|
187
|
+
spanDelta > 0
|
|
188
|
+
? "text-red-400"
|
|
189
|
+
: spanDelta < 0
|
|
190
|
+
? "text-green-400"
|
|
191
|
+
: "text-foreground"
|
|
192
|
+
}
|
|
193
|
+
>
|
|
194
|
+
{spanDelta > 0 ? `+${spanDelta}` : String(spanDelta)}
|
|
195
|
+
</span>
|
|
196
|
+
</div>
|
|
197
|
+
</div>
|
|
198
|
+
)}
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
{/* Side-by-side timelines */}
|
|
202
|
+
<div className="grid grid-cols-2 gap-4" style={{ height: "50vh" }}>
|
|
203
|
+
<div className="border border-border rounded-lg overflow-hidden">
|
|
204
|
+
<TraceTimeline
|
|
205
|
+
rows={rowsA ?? []}
|
|
206
|
+
isLoading={loadingA}
|
|
207
|
+
error={errorA ?? undefined}
|
|
208
|
+
/>
|
|
209
|
+
</div>
|
|
210
|
+
<div className="border border-border rounded-lg overflow-hidden">
|
|
211
|
+
<TraceTimeline
|
|
212
|
+
rows={rowsB ?? []}
|
|
213
|
+
isLoading={loadingB}
|
|
214
|
+
error={errorB ?? undefined}
|
|
215
|
+
/>
|
|
216
|
+
</div>
|
|
217
|
+
</div>
|
|
218
|
+
|
|
219
|
+
{/* Structural Diff Table */}
|
|
220
|
+
{!isLoading && diff.length > 0 && (
|
|
221
|
+
<div className="border border-border rounded-lg overflow-hidden">
|
|
222
|
+
<div className="px-4 py-3 border-b border-border bg-background">
|
|
223
|
+
<h3 className="text-sm font-medium text-foreground">
|
|
224
|
+
Structural Diff
|
|
225
|
+
</h3>
|
|
226
|
+
</div>
|
|
227
|
+
<div className="overflow-x-auto">
|
|
228
|
+
<table className="w-full text-sm">
|
|
229
|
+
<thead>
|
|
230
|
+
<tr className="border-b border-border bg-muted/30">
|
|
231
|
+
<th className="text-left px-4 py-2 text-muted-foreground font-medium">
|
|
232
|
+
Service
|
|
233
|
+
</th>
|
|
234
|
+
<th className="text-left px-4 py-2 text-muted-foreground font-medium">
|
|
235
|
+
Span
|
|
236
|
+
</th>
|
|
237
|
+
<th className="text-right px-4 py-2 text-muted-foreground font-medium">
|
|
238
|
+
Count A
|
|
239
|
+
</th>
|
|
240
|
+
<th className="text-right px-4 py-2 text-muted-foreground font-medium">
|
|
241
|
+
Count B
|
|
242
|
+
</th>
|
|
243
|
+
<th className="text-right px-4 py-2 text-muted-foreground font-medium">
|
|
244
|
+
Avg Dur A
|
|
245
|
+
</th>
|
|
246
|
+
<th className="text-right px-4 py-2 text-muted-foreground font-medium">
|
|
247
|
+
Avg Dur B
|
|
248
|
+
</th>
|
|
249
|
+
<th className="text-right px-4 py-2 text-muted-foreground font-medium">
|
|
250
|
+
Delta
|
|
251
|
+
</th>
|
|
252
|
+
</tr>
|
|
253
|
+
</thead>
|
|
254
|
+
<tbody>
|
|
255
|
+
{diff.map((row) => {
|
|
256
|
+
const onlyA = row.countA > 0 && row.countB === 0;
|
|
257
|
+
const onlyB = row.countA === 0 && row.countB > 0;
|
|
258
|
+
const rowBg = onlyA
|
|
259
|
+
? "bg-red-500/5"
|
|
260
|
+
: onlyB
|
|
261
|
+
? "bg-green-500/5"
|
|
262
|
+
: "";
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<tr
|
|
266
|
+
key={`${row.serviceName}::${row.spanName}`}
|
|
267
|
+
className={`border-b border-border/50 ${rowBg}`}
|
|
268
|
+
>
|
|
269
|
+
<td className="px-4 py-1.5 text-foreground">
|
|
270
|
+
{row.serviceName}
|
|
271
|
+
</td>
|
|
272
|
+
<td className="px-4 py-1.5 font-mono text-xs text-foreground">
|
|
273
|
+
{row.spanName}
|
|
274
|
+
</td>
|
|
275
|
+
<td className="px-4 py-1.5 text-right text-foreground">
|
|
276
|
+
{row.countA || (
|
|
277
|
+
<span className="text-muted-foreground">-</span>
|
|
278
|
+
)}
|
|
279
|
+
</td>
|
|
280
|
+
<td className="px-4 py-1.5 text-right text-foreground">
|
|
281
|
+
{row.countB || (
|
|
282
|
+
<span className="text-muted-foreground">-</span>
|
|
283
|
+
)}
|
|
284
|
+
</td>
|
|
285
|
+
<td className="px-4 py-1.5 text-right text-foreground">
|
|
286
|
+
{row.countA > 0 ? (
|
|
287
|
+
formatDuration(row.avgDurationA)
|
|
288
|
+
) : (
|
|
289
|
+
<span className="text-muted-foreground">-</span>
|
|
290
|
+
)}
|
|
291
|
+
</td>
|
|
292
|
+
<td className="px-4 py-1.5 text-right text-foreground">
|
|
293
|
+
{row.countB > 0 ? (
|
|
294
|
+
formatDuration(row.avgDurationB)
|
|
295
|
+
) : (
|
|
296
|
+
<span className="text-muted-foreground">-</span>
|
|
297
|
+
)}
|
|
298
|
+
</td>
|
|
299
|
+
<td className="px-4 py-1.5 text-right">
|
|
300
|
+
{row.countA > 0 && row.countB > 0 ? (
|
|
301
|
+
<span
|
|
302
|
+
className={
|
|
303
|
+
row.deltaMs > 0
|
|
304
|
+
? "text-red-400"
|
|
305
|
+
: row.deltaMs < 0
|
|
306
|
+
? "text-green-400"
|
|
307
|
+
: "text-foreground"
|
|
308
|
+
}
|
|
309
|
+
>
|
|
310
|
+
{formatDelta(row.deltaMs)}
|
|
311
|
+
</span>
|
|
312
|
+
) : (
|
|
313
|
+
<span
|
|
314
|
+
className={
|
|
315
|
+
onlyA ? "text-red-400" : "text-green-400"
|
|
316
|
+
}
|
|
317
|
+
>
|
|
318
|
+
{onlyA ? "removed" : "added"}
|
|
319
|
+
</span>
|
|
320
|
+
)}
|
|
321
|
+
</td>
|
|
322
|
+
</tr>
|
|
323
|
+
);
|
|
324
|
+
})}
|
|
325
|
+
</tbody>
|
|
326
|
+
</table>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
);
|
|
332
|
+
}
|
|
@@ -18,7 +18,6 @@ type Story = StoryObj<typeof TraceDetail>;
|
|
|
18
18
|
|
|
19
19
|
export const Default: Story = {
|
|
20
20
|
args: {
|
|
21
|
-
service: "api-gateway",
|
|
22
21
|
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
23
22
|
rows: mockTraceRows,
|
|
24
23
|
},
|
|
@@ -26,7 +25,6 @@ export const Default: Story = {
|
|
|
26
25
|
|
|
27
26
|
export const ErrorTrace: Story = {
|
|
28
27
|
args: {
|
|
29
|
-
service: "api-gateway",
|
|
30
28
|
traceId: "1bf8762027de54ee9559fc322d91420d",
|
|
31
29
|
rows: mockErrorTraceRows,
|
|
32
30
|
},
|
|
@@ -34,7 +32,6 @@ export const ErrorTrace: Story = {
|
|
|
34
32
|
|
|
35
33
|
export const Loading: Story = {
|
|
36
34
|
args: {
|
|
37
|
-
service: "api-gateway",
|
|
38
35
|
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
39
36
|
rows: [],
|
|
40
37
|
isLoading: true,
|
|
@@ -43,7 +40,6 @@ export const Loading: Story = {
|
|
|
43
40
|
|
|
44
41
|
export const Error: Story = {
|
|
45
42
|
args: {
|
|
46
|
-
service: "api-gateway",
|
|
47
43
|
traceId: "0af7651916cd43dd8448eb211c80319c",
|
|
48
44
|
rows: [],
|
|
49
45
|
error: new globalThis.Error("Failed to fetch trace"),
|
|
@@ -5,24 +5,24 @@ import type { SpanNode } from "../types.js";
|
|
|
5
5
|
type OtelTracesRow = denormalizedSignals.OtelTracesRow;
|
|
6
6
|
|
|
7
7
|
export interface TraceDetailProps {
|
|
8
|
-
service: string;
|
|
9
8
|
traceId: string;
|
|
10
9
|
rows: OtelTracesRow[];
|
|
11
10
|
isLoading?: boolean;
|
|
12
11
|
error?: Error;
|
|
13
12
|
selectedSpanId?: string;
|
|
14
13
|
onSpanClick?: (span: SpanNode) => void;
|
|
14
|
+
onSpanDeselect?: () => void;
|
|
15
15
|
onBack: () => void;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
export function TraceDetail({
|
|
19
|
-
service,
|
|
20
19
|
traceId,
|
|
21
20
|
rows,
|
|
22
21
|
isLoading,
|
|
23
22
|
error,
|
|
24
23
|
selectedSpanId,
|
|
25
24
|
onSpanClick,
|
|
25
|
+
onSpanDeselect,
|
|
26
26
|
onBack,
|
|
27
27
|
}: TraceDetailProps) {
|
|
28
28
|
return (
|
|
@@ -33,7 +33,7 @@ export function TraceDetail({
|
|
|
33
33
|
onClick={onBack}
|
|
34
34
|
className="hover:text-foreground transition-colors"
|
|
35
35
|
>
|
|
36
|
-
|
|
36
|
+
Traces
|
|
37
37
|
</button>
|
|
38
38
|
<span>/</span>
|
|
39
39
|
<span className="text-foreground font-mono text-xs">
|
|
@@ -47,6 +47,7 @@ export function TraceDetail({
|
|
|
47
47
|
error={error}
|
|
48
48
|
selectedSpanId={selectedSpanId}
|
|
49
49
|
onSpanClick={onSpanClick}
|
|
50
|
+
onSpanDeselect={onSpanDeselect}
|
|
50
51
|
/>
|
|
51
52
|
</div>
|
|
52
53
|
);
|