@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
@@ -19,9 +19,9 @@ import {
19
19
  LogTimeline,
20
20
  LogFilter,
21
21
  TabBar,
22
- ServiceList,
23
22
  TraceSearch,
24
23
  TraceDetail,
24
+ TraceComparison,
25
25
  KeyboardShortcutsProvider,
26
26
  useRegisterShortcuts,
27
27
  DynamicDashboard,
@@ -43,7 +43,7 @@ type OtelTracesRow = denormalizedSignals.OtelTracesRow;
43
43
  type Tab = "logs" | "services" | "metrics";
44
44
 
45
45
  const TABS: { key: Tab; label: string; shortcutKey: string }[] = [
46
- { key: "services", label: "Services", shortcutKey: "S" },
46
+ { key: "services", label: "Traces", shortcutKey: "T" },
47
47
  { key: "logs", label: "Logs", shortcutKey: "L" },
48
48
  { key: "metrics", label: "Metrics", shortcutKey: "M" },
49
49
  ];
@@ -54,9 +54,28 @@ const TABS: { key: Tab; label: string; shortcutKey: string }[] = [
54
54
 
55
55
  interface URLState {
56
56
  tab: Tab;
57
+ // Services tab search params
57
58
  service: string | null;
59
+ operation: string | null;
60
+ tags: string | null;
61
+ lookback: string | null;
62
+ tsMin: string | null;
63
+ tsMax: string | null;
64
+ minDuration: string | null;
65
+ maxDuration: string | null;
66
+ limit: number | null;
67
+ sort: string | null;
68
+ // Trace detail
58
69
  trace: string | null;
59
70
  span: string | null;
71
+ view: string | null;
72
+ uiFind: string | null;
73
+ // Comparison
74
+ compare: string | null;
75
+ // Minimap (phase 8)
76
+ viewStart: string | null;
77
+ viewEnd: string | null;
78
+ // Existing
60
79
  dashboardId: string | null;
61
80
  }
62
81
 
@@ -72,24 +91,79 @@ function readURLState(): URLState {
72
91
  : rawTab === "logs" || rawTab === "metrics"
73
92
  ? rawTab
74
93
  : "services";
75
- return { tab, service, trace, span, dashboardId };
94
+ const rawLimit = params.get("limit");
95
+ const limit = rawLimit ? parseInt(rawLimit, 10) : null;
96
+ return {
97
+ tab,
98
+ service,
99
+ operation: params.get("operation"),
100
+ tags: params.get("tags"),
101
+ lookback: params.get("lookback"),
102
+ tsMin: params.get("tsMin"),
103
+ tsMax: params.get("tsMax"),
104
+ minDuration: params.get("minDuration"),
105
+ maxDuration: params.get("maxDuration"),
106
+ limit: limit !== null && !isNaN(limit) ? limit : null,
107
+ sort: params.get("sort"),
108
+ trace,
109
+ span,
110
+ view: params.get("view"),
111
+ uiFind: params.get("uiFind"),
112
+ compare: params.get("compare"),
113
+ viewStart: params.get("viewStart"),
114
+ viewEnd: params.get("viewEnd"),
115
+ dashboardId,
116
+ };
76
117
  }
77
118
 
78
119
  function pushURLState(
79
120
  state: {
80
121
  tab: Tab;
81
122
  service?: string | null;
123
+ operation?: string | null;
124
+ tags?: string | null;
125
+ lookback?: string | null;
126
+ tsMin?: string | null;
127
+ tsMax?: string | null;
128
+ minDuration?: string | null;
129
+ maxDuration?: string | null;
130
+ limit?: number | null;
131
+ sort?: string | null;
82
132
  trace?: string | null;
83
133
  span?: string | null;
134
+ view?: string | null;
135
+ uiFind?: string | null;
136
+ compare?: string | null;
137
+ viewStart?: string | null;
138
+ viewEnd?: string | null;
84
139
  dashboardId?: string | null;
85
140
  },
86
141
  { replace = false }: { replace?: boolean } = {}
87
142
  ) {
88
143
  const params = new URLSearchParams();
89
144
  if (state.tab !== "services") params.set("tab", state.tab);
90
- if (state.service) params.set("service", state.service);
91
- if (state.trace) params.set("trace", state.trace);
92
- if (state.span) params.set("span", state.span);
145
+
146
+ if (state.tab === "services") {
147
+ if (state.service) params.set("service", state.service);
148
+ if (state.operation) params.set("operation", state.operation);
149
+ if (state.tags) params.set("tags", state.tags);
150
+ if (state.lookback) params.set("lookback", state.lookback);
151
+ if (state.tsMin) params.set("tsMin", state.tsMin);
152
+ if (state.tsMax) params.set("tsMax", state.tsMax);
153
+ if (state.minDuration) params.set("minDuration", state.minDuration);
154
+ if (state.maxDuration) params.set("maxDuration", state.maxDuration);
155
+ if (state.limit != null && state.limit !== 20)
156
+ params.set("limit", String(state.limit));
157
+ if (state.sort) params.set("sort", state.sort);
158
+ if (state.trace) params.set("trace", state.trace);
159
+ if (state.span) params.set("span", state.span);
160
+ if (state.view) params.set("view", state.view);
161
+ if (state.uiFind) params.set("uiFind", state.uiFind);
162
+ if (state.compare) params.set("compare", state.compare);
163
+ if (state.viewStart) params.set("viewStart", state.viewStart);
164
+ if (state.viewEnd) params.set("viewEnd", state.viewEnd);
165
+ }
166
+
93
167
  // Preserve dashboardId from current URL if not explicitly provided
94
168
  const dashboardId =
95
169
  state.dashboardId !== undefined
@@ -115,8 +189,22 @@ let _cachedSearch = "";
115
189
  let _cachedState: URLState = {
116
190
  tab: "services",
117
191
  service: null,
192
+ operation: null,
193
+ tags: null,
194
+ lookback: null,
195
+ tsMin: null,
196
+ tsMax: null,
197
+ minDuration: null,
198
+ maxDuration: null,
199
+ limit: null,
200
+ sort: null,
118
201
  trace: null,
119
202
  span: null,
203
+ view: null,
204
+ uiFind: null,
205
+ compare: null,
206
+ viewStart: null,
207
+ viewEnd: null,
120
208
  dashboardId: null,
121
209
  };
122
210
 
@@ -276,6 +364,42 @@ function parseDuration(input: string): string | undefined {
276
364
  return String(Math.round(value * multipliers[unit]!));
277
365
  }
278
366
 
367
+ // ---------------------------------------------------------------------------
368
+ // Logfmt helpers
369
+ // ---------------------------------------------------------------------------
370
+
371
+ function parseLogfmt(str: string): Record<string, string> {
372
+ const result: Record<string, string> = {};
373
+ const re = /(\w+)=(?:"([^"]*)"|([\S]*))/g;
374
+ let m: RegExpExecArray | null;
375
+ while ((m = re.exec(str)) !== null) {
376
+ const key = m[1];
377
+ if (key) result[key] = m[2] ?? m[3] ?? "";
378
+ }
379
+ return result;
380
+ }
381
+
382
+ export function serializeLogfmt(rec: Record<string, string>): string {
383
+ return Object.entries(rec)
384
+ .map(([k, v]) => (v.includes(" ") ? `${k}="${v}"` : `${k}=${v}`))
385
+ .join(" ");
386
+ }
387
+
388
+ // ---------------------------------------------------------------------------
389
+ // Lookback presets (ms values)
390
+ // ---------------------------------------------------------------------------
391
+
392
+ const LOOKBACK_MS: Record<string, number> = {
393
+ "5m": 5 * 60_000,
394
+ "15m": 15 * 60_000,
395
+ "30m": 30 * 60_000,
396
+ "1h": 60 * 60_000,
397
+ "2h": 2 * 60 * 60_000,
398
+ "6h": 6 * 60 * 60_000,
399
+ "12h": 12 * 60 * 60_000,
400
+ "24h": 24 * 60 * 60_000,
401
+ };
402
+
279
403
  // ---------------------------------------------------------------------------
280
404
  // Logs tab (live-tailing)
281
405
  // ---------------------------------------------------------------------------
@@ -357,212 +481,159 @@ function LogsTab() {
357
481
  // Services tab — data-fetching wrappers around extracted UI components
358
482
  // ---------------------------------------------------------------------------
359
483
 
360
- const SERVICES_DS: DataSource = {
361
- method: "searchTracesPage",
362
- params: { limit: 1000, sortOrder: "DESC" },
363
- };
364
-
365
- function ServiceListView({
366
- onSelect,
367
- }: {
368
- onSelect: (service: string) => void;
369
- }) {
370
- const { data, loading, error } = useKopaiData<{
371
- data: OtelTracesRow[];
372
- nextCursor: string | null;
373
- }>(SERVICES_DS);
374
-
375
- const services = useMemo(() => {
376
- if (!data?.data) return [];
377
- const names = new Set<string>();
378
- for (const row of data.data) {
379
- names.add(row.ServiceName ?? "unknown");
380
- }
381
- return Array.from(names)
382
- .sort()
383
- .map((name) => ({ name }));
384
- }, [data]);
385
-
386
- return (
387
- <ServiceList
388
- services={services}
389
- isLoading={loading}
390
- error={error ?? undefined}
391
- onSelect={onSelect}
392
- />
393
- );
484
+ interface TraceSummaryRow {
485
+ traceId: string;
486
+ rootServiceName: string;
487
+ rootSpanName: string;
488
+ startTimeNs: string;
489
+ durationNs: string;
490
+ spanCount: number;
491
+ errorCount: number;
492
+ services: Array<{ name: string; count: number; hasError: boolean }>;
394
493
  }
395
494
 
396
495
  function TraceSearchView({
397
- service,
398
- onBack,
399
496
  onSelectTrace,
497
+ onCompare,
400
498
  }: {
401
- service: string;
402
- onBack: () => void;
403
499
  onSelectTrace: (traceId: string) => void;
500
+ onCompare: (traceIds: [string, string]) => void;
404
501
  }) {
405
- const [ds, setDs] = useState<DataSource>(() => ({
406
- method: "searchTracesPage",
407
- params: { serviceName: service, limit: 20, sortOrder: "DESC" as const },
408
- }));
502
+ const urlState = useURLState();
503
+ const service = urlState.service;
504
+
505
+ // Build DataSource from URL state
506
+ const ds = useMemo<DataSource>(() => {
507
+ const params: Record<string, unknown> = {
508
+ limit: urlState.limit ?? 20,
509
+ sortOrder: "DESC" as const,
510
+ };
511
+ if (service) params.serviceName = service;
512
+ if (urlState.operation) params.spanName = urlState.operation;
513
+ if (urlState.lookback) {
514
+ const ms = LOOKBACK_MS[urlState.lookback];
515
+ if (ms) {
516
+ params.timestampMin = String((Date.now() - ms) * 1e6);
517
+ }
518
+ }
519
+ if (urlState.tsMin) params.timestampMin = urlState.tsMin;
520
+ if (urlState.tsMax) params.timestampMax = urlState.tsMax;
521
+ if (urlState.minDuration) {
522
+ const parsed = parseDuration(urlState.minDuration);
523
+ if (parsed) params.durationMin = parsed;
524
+ }
525
+ if (urlState.maxDuration) {
526
+ const parsed = parseDuration(urlState.maxDuration);
527
+ if (parsed) params.durationMax = parsed;
528
+ }
529
+ if (urlState.tags) {
530
+ const tagMap = parseLogfmt(urlState.tags);
531
+ if (Object.keys(tagMap).length > 0) params.tags = tagMap;
532
+ }
533
+ return {
534
+ method: "searchTraceSummariesPage",
535
+ params,
536
+ } as DataSource;
537
+ }, [
538
+ service,
539
+ urlState.operation,
540
+ urlState.lookback,
541
+ urlState.tsMin,
542
+ urlState.tsMax,
543
+ urlState.minDuration,
544
+ urlState.maxDuration,
545
+ urlState.limit,
546
+ urlState.tags,
547
+ ]);
409
548
 
410
549
  const handleSearch = useCallback(
411
550
  (filters: TraceSearchFilters) => {
412
- const params: Record<string, unknown> = {
413
- serviceName: service,
551
+ pushURLState({
552
+ tab: "services",
553
+ service: filters.service ?? service,
554
+ operation: filters.operation ?? null,
555
+ tags: filters.tags ?? null,
556
+ lookback: filters.lookback ?? null,
557
+ minDuration: filters.minDuration ?? null,
558
+ maxDuration: filters.maxDuration ?? null,
414
559
  limit: filters.limit,
415
- sortOrder: "DESC",
416
- };
417
- if (filters.operation) params.spanName = filters.operation;
418
- if (filters.lookbackMs) {
419
- params.timestampMin = String((Date.now() - filters.lookbackMs) * 1e6);
420
- }
421
- if (filters.minDuration) {
422
- const parsed = parseDuration(filters.minDuration);
423
- if (parsed) params.durationMin = parsed;
424
- }
425
- if (filters.maxDuration) {
426
- const parsed = parseDuration(filters.maxDuration);
427
- if (parsed) params.durationMax = parsed;
428
- }
429
- setDs({
430
- method: "searchTracesPage",
431
- params,
432
- } as DataSource);
560
+ });
433
561
  },
434
562
  [service]
435
563
  );
436
564
 
565
+ // Fetch trace summaries
437
566
  const { data, loading, error } = useKopaiData<{
438
- data: OtelTracesRow[];
567
+ data: TraceSummaryRow[];
439
568
  nextCursor: string | null;
440
569
  }>(ds);
441
570
 
442
- // Fetch full traces for each unique traceId so service breakdown is complete
443
- const client = useKopaiSDK();
444
- const [fullTraces, setFullTraces] = useState<Map<string, OtelTracesRow[]>>(
445
- () => new Map()
571
+ // Fetch services list
572
+ const serviceDs = useMemo<DataSource>(
573
+ () => ({ method: "getServices" as const }),
574
+ []
446
575
  );
576
+ const { data: servicesData } = useKopaiData<{ services: string[] }>(
577
+ serviceDs
578
+ );
579
+ const _services = servicesData?.services ?? [];
580
+
581
+ // Fetch operations for selected service
582
+ const operationDs = useMemo<DataSource | undefined>(
583
+ () =>
584
+ service
585
+ ? { method: "getOperations" as const, params: { serviceName: service } }
586
+ : undefined,
587
+ [service]
588
+ );
589
+ const { data: opsData } = useKopaiData<{ operations: string[] }>(operationDs);
590
+ const operations = opsData?.operations ?? [];
447
591
 
448
- useEffect(() => {
449
- if (!data?.data?.length) {
450
- setFullTraces(new Map());
451
- return;
452
- }
453
- const traceIds = [...new Set(data.data.map((r) => r.TraceId))];
454
- const ac = new AbortController();
455
-
456
- Promise.allSettled(
457
- traceIds.map((tid) =>
458
- client
459
- .getTrace(tid, { signal: ac.signal })
460
- .then((spans) => [tid, spans] as const)
461
- )
462
- )
463
- .then((results) => {
464
- if (!ac.signal.aborted) {
465
- const entries = results
466
- .filter(
467
- (
468
- r
469
- ): r is PromiseFulfilledResult<
470
- readonly [string, OtelTracesRow[]]
471
- > => r.status === "fulfilled"
472
- )
473
- .map((r) => r.value);
474
- setFullTraces(new Map(entries));
475
- }
476
- })
477
- .catch((err) => {
478
- if (!ac.signal.aborted)
479
- console.error("Failed to fetch full traces", err);
480
- });
481
-
482
- return () => ac.abort();
483
- }, [data, client]);
484
-
485
- // Derive unique operations for filter dropdown
486
- const operations = useMemo(() => {
487
- if (!data?.data) return [];
488
- const set = new Set<string>();
489
- for (const row of data.data) {
490
- if (row.SpanName) set.add(row.SpanName);
491
- }
492
- return Array.from(set).sort();
493
- }, [data]);
494
-
592
+ // Map TraceSummaryRow → TraceSummary
495
593
  const traces = useMemo<TraceSummary[]>(() => {
496
594
  if (!data?.data) return [];
497
- const grouped = new Map<string, OtelTracesRow[]>();
498
- for (const row of data.data) {
499
- const tid = row.TraceId;
500
- if (!grouped.has(tid)) grouped.set(tid, []);
501
- grouped.get(tid)!.push(row);
502
- }
595
+ return data.data.map((row) => ({
596
+ traceId: row.traceId,
597
+ rootSpanName: row.rootSpanName,
598
+ serviceName: row.rootServiceName,
599
+ durationMs: parseInt(row.durationNs, 10) / 1e6,
600
+ statusCode: row.errorCount > 0 ? "ERROR" : "OK",
601
+ timestampMs: parseInt(row.startTimeNs, 10) / 1e6,
602
+ spanCount: row.spanCount,
603
+ services: row.services,
604
+ errorCount: row.errorCount,
605
+ }));
606
+ }, [data]);
503
607
 
504
- return Array.from(grouped.entries()).map(([traceId, searchSpans]) => {
505
- const fullSpans = fullTraces.get(traceId);
506
- const spans = fullSpans ?? searchSpans;
507
-
508
- const root = spans.find((s) => !s.ParentSpanId) ?? spans[0]!;
509
- const durationNs = root.Duration ? parseInt(root.Duration, 10) : 0;
510
-
511
- const svcMap = new Map<string, { count: number; hasError: boolean }>();
512
- let errorCount = 0;
513
- for (const s of spans) {
514
- const svcName = s.ServiceName ?? "unknown";
515
- const entry = svcMap.get(svcName) ?? { count: 0, hasError: false };
516
- entry.count++;
517
- if (s.StatusCode === "ERROR") {
518
- entry.hasError = true;
519
- errorCount++;
520
- }
521
- svcMap.set(svcName, entry);
522
- }
523
- const services = Array.from(svcMap.entries())
524
- .map(([name, v]) => ({ name, count: v.count, hasError: v.hasError }))
525
- .sort((a, b) => b.count - a.count);
526
-
527
- return {
528
- traceId,
529
- rootSpanName: root.SpanName ?? "unknown",
530
- serviceName: root.ServiceName ?? "unknown",
531
- durationMs: durationNs / 1e6,
532
- statusCode: root.StatusCode ?? "UNSET",
533
- timestampMs: parseInt(root.Timestamp, 10) / 1e6,
534
- spanCount: spans.length,
535
- services,
536
- errorCount,
537
- };
538
- });
539
- }, [data, fullTraces]);
608
+ // Auto-execute on mount — the ds is already built from URL state,
609
+ // so useKopaiData fires automatically. No extra effect needed.
540
610
 
541
611
  return (
542
612
  <TraceSearch
543
- service={service}
613
+ services={_services}
614
+ service={service ?? ""}
544
615
  traces={traces}
545
616
  operations={operations}
546
617
  isLoading={loading}
547
618
  error={error ?? undefined}
548
619
  onSelectTrace={onSelectTrace}
549
- onBack={onBack}
620
+ onCompare={onCompare}
550
621
  onSearch={handleSearch}
551
622
  />
552
623
  );
553
624
  }
554
625
 
555
626
  function TraceDetailView({
556
- service,
557
627
  traceId,
558
628
  selectedSpanId,
559
629
  onSelectSpan,
630
+ onDeselectSpan,
560
631
  onBack,
561
632
  }: {
562
- service: string;
563
633
  traceId: string;
564
634
  selectedSpanId: string | null;
565
635
  onSelectSpan: (spanId: string) => void;
636
+ onDeselectSpan: () => void;
566
637
  onBack: () => void;
567
638
  }) {
568
639
  const ds = useMemo<DataSource>(
@@ -577,44 +648,42 @@ function TraceDetailView({
577
648
 
578
649
  return (
579
650
  <TraceDetail
580
- service={service}
581
651
  traceId={traceId}
582
652
  rows={data ?? []}
583
653
  isLoading={loading}
584
654
  error={error ?? undefined}
585
655
  selectedSpanId={selectedSpanId ?? undefined}
586
656
  onSpanClick={(span) => onSelectSpan(span.spanId)}
657
+ onSpanDeselect={onDeselectSpan}
587
658
  onBack={onBack}
588
659
  />
589
660
  );
590
661
  }
591
662
 
592
663
  function ServicesTab({
593
- selectedService,
594
664
  selectedTraceId,
595
665
  selectedSpanId,
596
- onSelectService,
666
+ compareParam,
597
667
  onSelectTrace,
598
668
  onSelectSpan,
599
- onBackToServices,
600
- onBackToTraceList,
669
+ onDeselectSpan,
670
+ onBack,
671
+ onCompare,
601
672
  }: {
602
- selectedService: string | null;
603
673
  selectedTraceId: string | null;
604
674
  selectedSpanId: string | null;
605
- onSelectService: (service: string) => void;
675
+ compareParam: string | null;
606
676
  onSelectTrace: (traceId: string) => void;
607
677
  onSelectSpan: (spanId: string) => void;
608
- onBackToServices: () => void;
609
- onBackToTraceList: () => void;
678
+ onDeselectSpan: () => void;
679
+ onBack: () => void;
680
+ onCompare: (traceIds: [string, string]) => void;
610
681
  }) {
611
682
  useRegisterShortcuts("services-tab", SERVICES_SHORTCUTS);
612
683
 
613
- // Backspace → navigate back based on drill-down depth
614
- const backToServicesRef = useRef(onBackToServices);
615
- backToServicesRef.current = onBackToServices;
616
- const backToTraceListRef = useRef(onBackToTraceList);
617
- backToTraceListRef.current = onBackToTraceList;
684
+ // Backspace → navigate back
685
+ const backRef = useRef(onBack);
686
+ backRef.current = onBack;
618
687
  useEffect(() => {
619
688
  const handleKeyDown = (e: KeyboardEvent) => {
620
689
  if (
@@ -625,46 +694,49 @@ function ServicesTab({
625
694
  return;
626
695
  if (e.key === "Backspace") {
627
696
  e.preventDefault();
628
- if (selectedTraceId && selectedService) {
629
- backToTraceListRef.current();
630
- } else if (selectedService) {
631
- backToServicesRef.current();
632
- }
697
+ backRef.current();
633
698
  }
634
699
  };
635
700
  window.addEventListener("keydown", handleKeyDown);
636
701
  return () => window.removeEventListener("keydown", handleKeyDown);
637
- }, [selectedService, selectedTraceId]);
702
+ }, []);
638
703
 
639
- if (selectedTraceId && selectedService) {
704
+ // Comparison view
705
+ if (compareParam) {
706
+ const [traceIdA, traceIdB] = compareParam.split(",");
707
+ if (traceIdA && traceIdB) {
708
+ return (
709
+ <TraceComparison
710
+ traceIdA={traceIdA}
711
+ traceIdB={traceIdB}
712
+ onBack={onBack}
713
+ />
714
+ );
715
+ }
716
+ }
717
+
718
+ if (selectedTraceId) {
640
719
  return (
641
720
  <TraceDetailView
642
- service={selectedService}
643
721
  traceId={selectedTraceId}
644
722
  selectedSpanId={selectedSpanId}
645
723
  onSelectSpan={onSelectSpan}
646
- onBack={onBackToTraceList}
724
+ onDeselectSpan={onDeselectSpan}
725
+ onBack={onBack}
647
726
  />
648
727
  );
649
728
  }
650
- if (selectedService) {
651
- return (
652
- <TraceSearchView
653
- service={selectedService}
654
- onBack={onBackToServices}
655
- onSelectTrace={onSelectTrace}
656
- />
657
- );
658
- }
659
- return <ServiceListView onSelect={onSelectService} />;
729
+
730
+ // Default: TraceSearchView directly
731
+ return (
732
+ <TraceSearchView onSelectTrace={onSelectTrace} onCompare={onCompare} />
733
+ );
660
734
  }
661
735
 
662
736
  // ---------------------------------------------------------------------------
663
737
  // Metrics tab — DynamicDashboard
664
738
  // ---------------------------------------------------------------------------
665
739
 
666
- const DASHBOARDS_API_BASE = "/dashboards";
667
-
668
740
  const METRICS_TREE = {
669
741
  root: "root",
670
742
  elements: {
@@ -715,16 +787,17 @@ const METRICS_TREE = {
715
787
  },
716
788
  };
717
789
 
718
- function useDashboardTree(dashboardId: string | null) {
790
+ function useDashboardTree(
791
+ client: Pick<KopaiClient, "getDashboard">,
792
+ dashboardId: string | null
793
+ ) {
719
794
  const { data, isFetching, error } = useQuery<UITree, Error>({
720
795
  queryKey: ["dashboard-tree", dashboardId],
721
796
  queryFn: async ({ signal }) => {
722
- const res = await fetch(`${DASHBOARDS_API_BASE}/${dashboardId}`, {
723
- signal,
724
- });
725
- if (!res.ok) throw new Error(`Failed to load dashboard: ${res.status}`);
726
- const json = await res.json();
727
- const parsed = observabilityCatalog.uiTreeSchema.safeParse(json.uiTree);
797
+ const dashboard = await client.getDashboard(dashboardId!, { signal });
798
+ const parsed = observabilityCatalog.uiTreeSchema.safeParse(
799
+ dashboard.uiTree
800
+ );
728
801
  if (!parsed.success) {
729
802
  const issue = parsed.error.issues[0];
730
803
  const path = issue?.path.length ? issue.path.join(".") + ": " : "";
@@ -747,7 +820,7 @@ function useDashboardTree(dashboardId: string | null) {
747
820
  function MetricsTab() {
748
821
  const kopaiClient = useKopaiSDK();
749
822
  const { dashboardId } = useURLState();
750
- const { loading, error, tree } = useDashboardTree(dashboardId);
823
+ const { loading, error, tree } = useDashboardTree(kopaiClient, dashboardId);
751
824
 
752
825
  if (loading)
753
826
  return (
@@ -779,52 +852,57 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
779
852
  const activeClient = client ?? getDefaultClient();
780
853
  const {
781
854
  tab: activeTab,
782
- service: selectedService,
783
855
  trace: selectedTraceId,
784
856
  span: selectedSpanId,
857
+ compare: compareParam,
785
858
  } = useURLState();
786
859
 
787
860
  const handleTabChange = useCallback((tab: Tab) => {
788
861
  pushURLState({ tab });
789
862
  }, []);
790
863
 
791
- const handleSelectService = useCallback((service: string) => {
792
- pushURLState({ tab: "services", service });
864
+ const handleSelectTrace = useCallback((traceId: string) => {
865
+ pushURLState({ ...readURLState(), tab: "services", trace: traceId });
793
866
  }, []);
794
867
 
795
- const handleSelectTrace = useCallback(
796
- (traceId: string) => {
797
- pushURLState({
798
- tab: "services",
799
- service: selectedService,
800
- trace: traceId,
801
- });
802
- },
803
- [selectedService]
804
- );
868
+ const handleSelectSpan = useCallback((spanId: string) => {
869
+ pushURLState(
870
+ { ...readURLState(), tab: "services", span: spanId },
871
+ { replace: true }
872
+ );
873
+ }, []);
805
874
 
806
- const handleSelectSpan = useCallback(
807
- (spanId: string) => {
808
- pushURLState(
809
- {
810
- tab: "services",
811
- service: selectedService,
812
- trace: selectedTraceId,
813
- span: spanId,
814
- },
815
- { replace: true }
816
- );
817
- },
818
- [selectedService, selectedTraceId]
819
- );
875
+ const handleDeselectSpan = useCallback(() => {
876
+ pushURLState({ ...readURLState(), span: null }, { replace: true });
877
+ }, []);
820
878
 
821
- const handleBackToServices = useCallback(() => {
822
- pushURLState({ tab: "services" });
879
+ const handleCompare = useCallback((traceIds: [string, string]) => {
880
+ pushURLState({
881
+ ...readURLState(),
882
+ tab: "services",
883
+ trace: null,
884
+ span: null,
885
+ view: null,
886
+ uiFind: null,
887
+ viewStart: null,
888
+ viewEnd: null,
889
+ compare: traceIds.join(","),
890
+ });
823
891
  }, []);
824
892
 
825
- const handleBackToTraceList = useCallback(() => {
826
- pushURLState({ tab: "services", service: selectedService });
827
- }, [selectedService]);
893
+ const handleBack = useCallback(() => {
894
+ pushURLState({
895
+ ...readURLState(),
896
+ tab: "services",
897
+ trace: null,
898
+ span: null,
899
+ view: null,
900
+ uiFind: null,
901
+ viewStart: null,
902
+ viewEnd: null,
903
+ compare: null,
904
+ });
905
+ }, []);
828
906
 
829
907
  return (
830
908
  <KopaiSDKProvider client={activeClient}>
@@ -842,14 +920,14 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
842
920
  {activeTab === "logs" && <LogsTab />}
843
921
  {activeTab === "services" && (
844
922
  <ServicesTab
845
- selectedService={selectedService}
846
923
  selectedTraceId={selectedTraceId}
847
924
  selectedSpanId={selectedSpanId}
848
- onSelectService={handleSelectService}
925
+ compareParam={compareParam}
849
926
  onSelectTrace={handleSelectTrace}
850
927
  onSelectSpan={handleSelectSpan}
851
- onBackToServices={handleBackToServices}
852
- onBackToTraceList={handleBackToTraceList}
928
+ onDeselectSpan={handleDeselectSpan}
929
+ onBack={handleBack}
930
+ onCompare={handleCompare}
853
931
  />
854
932
  )}
855
933
  {activeTab === "metrics" && <MetricsTab />}