@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.
Files changed (50) hide show
  1. package/dist/index.cjs +2451 -1157
  2. package/dist/index.d.cts +36 -7
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +36 -7
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2399 -1099
  7. package/dist/index.mjs.map +1 -1
  8. package/package.json +13 -13
  9. package/src/components/KeyboardShortcuts/KeyboardShortcutsProvider.tsx +7 -7
  10. package/src/components/observability/DynamicDashboard/DynamicDashboard.test.tsx +5 -0
  11. package/src/components/observability/LogTimeline/LogFilter.tsx +5 -1
  12. package/src/components/observability/LogTimeline/index.tsx +6 -2
  13. package/src/components/observability/MetricHistogram/index.tsx +20 -19
  14. package/src/components/observability/MetricTimeSeries/index.tsx +25 -14
  15. package/src/components/observability/ServiceList/shortcuts.ts +1 -1
  16. package/src/components/observability/TraceComparison/index.tsx +332 -0
  17. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
  18. package/src/components/observability/TraceDetail/index.tsx +4 -3
  19. package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
  20. package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
  21. package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
  22. package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
  23. package/src/components/observability/TraceSearch/index.tsx +211 -218
  24. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
  25. package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
  26. package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
  27. package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
  28. package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
  29. package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
  30. package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
  31. package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
  32. package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
  33. package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
  34. package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
  35. package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
  36. package/src/components/observability/TraceTimeline/index.tsx +254 -110
  37. package/src/components/observability/index.ts +15 -0
  38. package/src/components/observability/renderers/OtelLogTimeline.tsx +9 -5
  39. package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
  40. package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
  41. package/src/components/observability/utils/flatten-tree.ts +15 -0
  42. package/src/components/observability/utils/time.ts +9 -0
  43. package/src/hooks/use-kopai-data.test.ts +4 -0
  44. package/src/hooks/use-kopai-data.ts +11 -0
  45. package/src/hooks/use-live-logs.test.ts +4 -0
  46. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
  47. package/src/lib/component-catalog.ts +15 -0
  48. package/src/pages/observability.test.tsx +16 -12
  49. package/src/pages/observability.tsx +323 -245
  50. 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.7.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.19",
35
- "recharts": "^3.7.0",
36
- "@kopai/sdk": "0.5.0",
37
- "@kopai/core": "0.7.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.13",
45
- "@storybook/react": "^10.2.13",
46
- "@storybook/react-vite": "^10.2.13",
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": "^5.1.4",
50
+ "@vitejs/plugin-react": "^6.0.1",
51
51
  "@tailwindcss/postcss": "^4.2.1",
52
52
  "jsdom": "^28.1.0",
53
- "postcss": "^8.5.3",
53
+ "postcss": "^8.5.8",
54
54
  "react": "^19.2.4",
55
55
  "react-dom": "^19.2.4",
56
- "storybook": "^10.2.13",
56
+ "storybook": "^10.2.19",
57
57
  "tailwindcss": "^4.2.1",
58
- "tsdown": "^0.20.3",
59
- "vite": "^7.3.1",
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", "S"], description: "Services tab" },
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
- const target = e.target as HTMLElement;
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 === "S") {
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 (ref.current && !ref.current.contains(e.target as Node)) {
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 (isFormField && e.key === "Escape") {
273
- (e.target as HTMLElement).blur();
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
- bucket[seriesName] = ((bucket[seriesName] as number) ?? 0) + count;
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?: readonly {
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 bucket = payload[0]?.payload;
319
- if (!bucket) return null;
322
+ const raw = payload[0]?.payload;
323
+ if (!isBucketData(raw)) return null;
320
324
 
321
325
  const boundsLabel = boundsScale
322
- ? `${formatDisplayValue(bucket.lowerBound, boundsScale)} – ${bucket.upperBound === Infinity ? "∞" : formatDisplayValue(bucket.upperBound, boundsScale)}`
323
- : bucket.bucket;
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
- {payload.map((entry, i) => (
331
- <p key={i} className="text-sm" style={{ color: entry.color }}>
332
- <span className="font-medium">
333
- {displayLabelMap.get(entry.dataKey) ?? entry.dataKey}:
334
- </span>{" "}
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
- if (
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
- continue; // TimeSeries only handles Gauge/Sum
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?: readonly { dataKey: string; value: number; color: string }[];
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
- {payload.map((entry, i) => (
464
- <p key={i} className="text-sm" style={{ color: entry.color }}>
465
- <span className="font-medium">
466
- {displayLabelMap.get(entry.dataKey) ?? entry.dataKey}:
467
- </span>{" "}
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
  }
@@ -1,6 +1,6 @@
1
1
  import type { ShortcutGroup } from "../../KeyboardShortcuts/types.js";
2
2
 
3
3
  export const SERVICES_SHORTCUTS: ShortcutGroup = {
4
- name: "Services",
4
+ name: "Traces",
5
5
  shortcuts: [{ keys: ["Backspace"], description: "Go back" }],
6
6
  };
@@ -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
+ &larr; 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"),