@kopai/ui 0.7.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 +2451 -1157
- 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 +2399 -1099
- 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 +25 -14
- 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/OtelLogTimeline.tsx +9 -5
- 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 +4 -0
- package/src/hooks/use-kopai-data.ts +11 -0
- package/src/hooks/use-live-logs.test.ts +4 -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 +16 -12
- package/src/pages/observability.tsx +323 -245
- package/src/providers/kopai-provider.tsx +4 -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/
|
|
37
|
-
"@kopai/
|
|
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,
|
|
@@ -96,12 +98,26 @@ function buildMetrics(rows: OtelMetricsRow[]): ParsedMetricGroup[] {
|
|
|
96
98
|
for (const row of rows) {
|
|
97
99
|
const name = row.MetricName ?? "unknown";
|
|
98
100
|
const type = row.MetricType;
|
|
99
|
-
|
|
101
|
+
|
|
102
|
+
// Extract scalar value depending on metric type
|
|
103
|
+
let value: number | undefined;
|
|
104
|
+
if (type === "Gauge" || type === "Sum") {
|
|
105
|
+
value = "Value" in row ? row.Value : undefined;
|
|
106
|
+
} else if (
|
|
100
107
|
type === "Histogram" ||
|
|
101
108
|
type === "ExponentialHistogram" ||
|
|
102
109
|
type === "Summary"
|
|
103
|
-
)
|
|
104
|
-
|
|
110
|
+
) {
|
|
111
|
+
// Use mean (Sum/Count) for distribution metrics
|
|
112
|
+
const sum = "Sum" in row ? (row as { Sum?: number }).Sum : undefined;
|
|
113
|
+
const count =
|
|
114
|
+
"Count" in row ? (row as { Count?: number }).Count : undefined;
|
|
115
|
+
if (sum != null && count != null && count > 0) {
|
|
116
|
+
value = sum / count;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (value === undefined) continue;
|
|
105
121
|
|
|
106
122
|
if (!metricMap.has(name)) metricMap.set(name, new Map());
|
|
107
123
|
if (!metricMeta.has(name))
|
|
@@ -136,8 +152,6 @@ function buildMetrics(rows: OtelMetricsRow[]): ParsedMetricGroup[] {
|
|
|
136
152
|
});
|
|
137
153
|
}
|
|
138
154
|
|
|
139
|
-
if (!("Value" in row)) continue;
|
|
140
|
-
const value = row.Value;
|
|
141
155
|
const timestamp = parseInt(row.TimeUnix, 10) / 1e6;
|
|
142
156
|
seriesMap.get(seriesKey)!.dataPoints.push({ timestamp, value });
|
|
143
157
|
}
|
|
@@ -449,7 +463,7 @@ function CustomTooltip({
|
|
|
449
463
|
displayLabelMap,
|
|
450
464
|
}: {
|
|
451
465
|
active?: boolean;
|
|
452
|
-
payload?:
|
|
466
|
+
payload?: TooltipPayload;
|
|
453
467
|
label?: string | number;
|
|
454
468
|
formatTime: (ts: number) => string;
|
|
455
469
|
formatValue: (val: number) => string;
|
|
@@ -460,14 +474,11 @@ function CustomTooltip({
|
|
|
460
474
|
return (
|
|
461
475
|
<div className="bg-background border border-gray-700 rounded-lg p-3 shadow-lg">
|
|
462
476
|
<p className="text-gray-400 text-xs mb-2">{formatTime(ts)}</p>
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
{formatValue(entry.value)}
|
|
469
|
-
</p>
|
|
470
|
-
))}
|
|
477
|
+
<TooltipEntryList
|
|
478
|
+
payload={payload}
|
|
479
|
+
displayLabelMap={displayLabelMap}
|
|
480
|
+
formatValue={formatValue}
|
|
481
|
+
/>
|
|
471
482
|
</div>
|
|
472
483
|
);
|
|
473
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"),
|