@kopai/ui 0.8.0 → 0.10.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 (52) hide show
  1. package/dist/index.cjs +2704 -1288
  2. package/dist/index.d.cts +38 -1
  3. package/dist/index.d.cts.map +1 -1
  4. package/dist/index.d.mts +38 -1
  5. package/dist/index.d.mts.map +1 -1
  6. package/dist/index.mjs +2722 -1300
  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 +8 -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/MetricStat/index.tsx +12 -4
  15. package/src/components/observability/MetricTimeSeries/index.tsx +8 -9
  16. package/src/components/observability/ServiceList/shortcuts.ts +1 -1
  17. package/src/components/observability/TraceComparison/index.tsx +332 -0
  18. package/src/components/observability/TraceDetail/TraceDetail.stories.tsx +0 -4
  19. package/src/components/observability/TraceDetail/index.tsx +4 -3
  20. package/src/components/observability/TraceSearch/DurationBar.tsx +38 -0
  21. package/src/components/observability/TraceSearch/ScatterPlot.tsx +135 -0
  22. package/src/components/observability/TraceSearch/SearchForm.tsx +195 -0
  23. package/src/components/observability/TraceSearch/SortDropdown.tsx +32 -0
  24. package/src/components/observability/TraceSearch/index.tsx +211 -218
  25. package/src/components/observability/TraceTimeline/DetailPane/EventsTab.tsx +1 -7
  26. package/src/components/observability/TraceTimeline/FlamegraphView.tsx +232 -0
  27. package/src/components/observability/TraceTimeline/GraphView.tsx +322 -0
  28. package/src/components/observability/TraceTimeline/Minimap.tsx +260 -0
  29. package/src/components/observability/TraceTimeline/SpanDetailInline.tsx +184 -0
  30. package/src/components/observability/TraceTimeline/SpanRow.tsx +14 -24
  31. package/src/components/observability/TraceTimeline/SpanSearch.tsx +76 -0
  32. package/src/components/observability/TraceTimeline/StatisticsView.tsx +223 -0
  33. package/src/components/observability/TraceTimeline/TimeRuler.tsx +54 -0
  34. package/src/components/observability/TraceTimeline/TimelineBar.tsx +18 -2
  35. package/src/components/observability/TraceTimeline/TraceHeader.tsx +116 -51
  36. package/src/components/observability/TraceTimeline/ViewTabs.tsx +34 -0
  37. package/src/components/observability/TraceTimeline/index.tsx +254 -110
  38. package/src/components/observability/index.ts +15 -0
  39. package/src/components/observability/renderers/OtelMetricStat.tsx +40 -0
  40. package/src/components/observability/renderers/OtelTraceDetail.tsx +1 -4
  41. package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
  42. package/src/components/observability/utils/flatten-tree.ts +15 -0
  43. package/src/components/observability/utils/time.ts +9 -0
  44. package/src/hooks/use-kopai-data.test.ts +34 -0
  45. package/src/hooks/use-kopai-data.ts +23 -5
  46. package/src/hooks/use-live-logs.test.ts +4 -0
  47. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
  48. package/src/lib/component-catalog.ts +15 -0
  49. package/src/lib/renderer.test.tsx +2 -0
  50. package/src/pages/observability.test.tsx +8 -0
  51. package/src/pages/observability.tsx +397 -236
  52. 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.8.0",
3
+ "version": "0.10.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/core": "0.7.0",
37
- "@kopai/sdk": "0.5.0"
34
+ "@tanstack/react-virtual": "^3.13.22",
35
+ "recharts": "^3.8.0",
36
+ "@kopai/core": "0.9.0",
37
+ "@kopai/sdk": "0.7.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;
@@ -17,6 +17,9 @@ function createMockClient(): MockClient {
17
17
  searchTracesPage: vi.fn().mockResolvedValue({ data: [] }),
18
18
  searchLogsPage: vi.fn().mockResolvedValue({ data: [] }),
19
19
  searchMetricsPage: vi.fn().mockResolvedValue({ data: [] }),
20
+ searchAggregatedMetrics: vi
21
+ .fn()
22
+ .mockResolvedValue({ data: [], nextCursor: null }),
20
23
  getTrace: vi.fn().mockResolvedValue({ data: [] }),
21
24
  discoverMetrics: vi.fn().mockResolvedValue({ data: [] }),
22
25
  searchTraces: vi.fn().mockResolvedValue({ data: [] }),
@@ -28,6 +31,11 @@ function createMockClient(): MockClient {
28
31
  .fn()
29
32
  .mockResolvedValue({ data: [], nextCursor: null }),
30
33
  searchDashboards: vi.fn().mockReturnValue((async function* () {})()),
34
+ getServices: vi.fn().mockResolvedValue({ services: [] }),
35
+ getOperations: vi.fn().mockResolvedValue({ operations: [] }),
36
+ searchTraceSummariesPage: vi
37
+ .fn()
38
+ .mockResolvedValue({ data: [], nextCursor: null }),
31
39
  };
32
40
  }
33
41
 
@@ -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
  }
@@ -16,6 +16,10 @@ export interface ThresholdConfig {
16
16
 
17
17
  export interface MetricStatProps {
18
18
  rows: OtelMetricsRow[];
19
+ /** Pre-computed value (e.g. from aggregated queries). Bypasses row extraction when set. */
20
+ value?: number;
21
+ /** Unit string for formatting when using pre-computed value. */
22
+ unit?: string;
19
23
  isLoading?: boolean;
20
24
  error?: Error;
21
25
  label?: string;
@@ -140,6 +144,8 @@ function buildStatData(rows: OtelMetricsRow[]): {
140
144
 
141
145
  export function MetricStat({
142
146
  rows,
147
+ value: directValue,
148
+ unit: directUnit,
143
149
  isLoading = false,
144
150
  error,
145
151
  label,
@@ -155,10 +161,12 @@ export function MetricStat({
155
161
  colorBackground,
156
162
  colorValue = false,
157
163
  }: MetricStatProps) {
158
- const { latestValue, unit, timestamp, dataPoints, metricName } = useMemo(
159
- () => buildStatData(rows),
160
- [rows]
161
- );
164
+ const statData = useMemo(() => buildStatData(rows), [rows]);
165
+
166
+ // Pre-computed value (aggregated queries) bypasses row extraction
167
+ const latestValue = directValue ?? statData.latestValue;
168
+ const unit = directUnit ?? statData.unit;
169
+ const { timestamp, dataPoints, metricName } = statData;
162
170
 
163
171
  const sparklineData = useMemo(() => {
164
172
  if (!showSparkline || dataPoints.length === 0) return [];
@@ -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?: readonly { dataKey: string; value: number; color: string }[];
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
- {payload.map((entry, i) => (
476
- <p key={i} className="text-sm" style={{ color: entry.color }}>
477
- <span className="font-medium">
478
- {displayLabelMap.get(entry.dataKey) ?? entry.dataKey}:
479
- </span>{" "}
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
  }
@@ -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"),