@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
@@ -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,38 +694,43 @@ 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
+ }, []);
703
+
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
+ }
638
717
 
639
- if (selectedTraceId && selectedService) {
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}
647
- />
648
- );
649
- }
650
- if (selectedService) {
651
- return (
652
- <TraceSearchView
653
- service={selectedService}
654
- onBack={onBackToServices}
655
- onSelectTrace={onSelectTrace}
724
+ onDeselectSpan={onDeselectSpan}
725
+ onBack={onBack}
656
726
  />
657
727
  );
658
728
  }
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
  // ---------------------------------------------------------------------------
@@ -669,7 +743,14 @@ const METRICS_TREE = {
669
743
  root: {
670
744
  key: "root",
671
745
  type: "Stack" as const,
672
- children: ["heading", "description", "discovery-card"],
746
+ children: [
747
+ "heading",
748
+ "ingestion-heading",
749
+ "ingestion-grid",
750
+ "discovery-heading",
751
+ "description",
752
+ "discovery-card",
753
+ ],
673
754
  parentKey: "",
674
755
  props: {
675
756
  direction: "vertical" as const,
@@ -684,6 +765,81 @@ const METRICS_TREE = {
684
765
  parentKey: "root",
685
766
  props: { text: "Metrics", level: "h2" as const },
686
767
  },
768
+ "ingestion-heading": {
769
+ key: "ingestion-heading",
770
+ type: "Heading" as const,
771
+ children: [],
772
+ parentKey: "root",
773
+ props: { text: "OTEL Ingestion", level: "h3" as const },
774
+ },
775
+ "ingestion-grid": {
776
+ key: "ingestion-grid",
777
+ type: "Grid" as const,
778
+ children: ["card-bytes", "card-requests"],
779
+ parentKey: "root",
780
+ props: { columns: 2, gap: "md" as const },
781
+ },
782
+ "card-bytes": {
783
+ key: "card-bytes",
784
+ type: "Card" as const,
785
+ children: ["stat-bytes"],
786
+ parentKey: "ingestion-grid",
787
+ props: {
788
+ title: "Total Bytes Ingested",
789
+ description: null,
790
+ padding: null,
791
+ },
792
+ },
793
+ "stat-bytes": {
794
+ key: "stat-bytes",
795
+ type: "MetricStat" as const,
796
+ children: [],
797
+ parentKey: "card-bytes",
798
+ dataSource: {
799
+ method: "searchMetricsPage" as const,
800
+ params: {
801
+ metricType: "Sum" as const,
802
+ metricName: "kopai.ingestion.bytes",
803
+ aggregate: "sum" as const,
804
+ },
805
+ refetchIntervalMs: 10_000,
806
+ },
807
+ props: { label: "Bytes", showSparkline: false },
808
+ },
809
+ "card-requests": {
810
+ key: "card-requests",
811
+ type: "Card" as const,
812
+ children: ["stat-requests"],
813
+ parentKey: "ingestion-grid",
814
+ props: {
815
+ title: "Total Requests",
816
+ description: null,
817
+ padding: null,
818
+ },
819
+ },
820
+ "stat-requests": {
821
+ key: "stat-requests",
822
+ type: "MetricStat" as const,
823
+ children: [],
824
+ parentKey: "card-requests",
825
+ dataSource: {
826
+ method: "searchMetricsPage" as const,
827
+ params: {
828
+ metricType: "Sum" as const,
829
+ metricName: "kopai.ingestion.requests",
830
+ aggregate: "sum" as const,
831
+ },
832
+ refetchIntervalMs: 10_000,
833
+ },
834
+ props: { label: "Requests", showSparkline: false },
835
+ },
836
+ "discovery-heading": {
837
+ key: "discovery-heading",
838
+ type: "Heading" as const,
839
+ children: [],
840
+ parentKey: "root",
841
+ props: { text: "Discovered Metrics", level: "h3" as const },
842
+ },
687
843
  description: {
688
844
  key: "description",
689
845
  type: "Text" as const,
@@ -778,52 +934,57 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
778
934
  const activeClient = client ?? getDefaultClient();
779
935
  const {
780
936
  tab: activeTab,
781
- service: selectedService,
782
937
  trace: selectedTraceId,
783
938
  span: selectedSpanId,
939
+ compare: compareParam,
784
940
  } = useURLState();
785
941
 
786
942
  const handleTabChange = useCallback((tab: Tab) => {
787
943
  pushURLState({ tab });
788
944
  }, []);
789
945
 
790
- const handleSelectService = useCallback((service: string) => {
791
- pushURLState({ tab: "services", service });
946
+ const handleSelectTrace = useCallback((traceId: string) => {
947
+ pushURLState({ ...readURLState(), tab: "services", trace: traceId });
792
948
  }, []);
793
949
 
794
- const handleSelectTrace = useCallback(
795
- (traceId: string) => {
796
- pushURLState({
797
- tab: "services",
798
- service: selectedService,
799
- trace: traceId,
800
- });
801
- },
802
- [selectedService]
803
- );
950
+ const handleSelectSpan = useCallback((spanId: string) => {
951
+ pushURLState(
952
+ { ...readURLState(), tab: "services", span: spanId },
953
+ { replace: true }
954
+ );
955
+ }, []);
804
956
 
805
- const handleSelectSpan = useCallback(
806
- (spanId: string) => {
807
- pushURLState(
808
- {
809
- tab: "services",
810
- service: selectedService,
811
- trace: selectedTraceId,
812
- span: spanId,
813
- },
814
- { replace: true }
815
- );
816
- },
817
- [selectedService, selectedTraceId]
818
- );
957
+ const handleDeselectSpan = useCallback(() => {
958
+ pushURLState({ ...readURLState(), span: null }, { replace: true });
959
+ }, []);
819
960
 
820
- const handleBackToServices = useCallback(() => {
821
- pushURLState({ tab: "services" });
961
+ const handleCompare = useCallback((traceIds: [string, string]) => {
962
+ pushURLState({
963
+ ...readURLState(),
964
+ tab: "services",
965
+ trace: null,
966
+ span: null,
967
+ view: null,
968
+ uiFind: null,
969
+ viewStart: null,
970
+ viewEnd: null,
971
+ compare: traceIds.join(","),
972
+ });
822
973
  }, []);
823
974
 
824
- const handleBackToTraceList = useCallback(() => {
825
- pushURLState({ tab: "services", service: selectedService });
826
- }, [selectedService]);
975
+ const handleBack = useCallback(() => {
976
+ pushURLState({
977
+ ...readURLState(),
978
+ tab: "services",
979
+ trace: null,
980
+ span: null,
981
+ view: null,
982
+ uiFind: null,
983
+ viewStart: null,
984
+ viewEnd: null,
985
+ compare: null,
986
+ });
987
+ }, []);
827
988
 
828
989
  return (
829
990
  <KopaiSDKProvider client={activeClient}>
@@ -841,14 +1002,14 @@ export default function ObservabilityPage({ client }: ObservabilityPageProps) {
841
1002
  {activeTab === "logs" && <LogsTab />}
842
1003
  {activeTab === "services" && (
843
1004
  <ServicesTab
844
- selectedService={selectedService}
845
1005
  selectedTraceId={selectedTraceId}
846
1006
  selectedSpanId={selectedSpanId}
847
- onSelectService={handleSelectService}
1007
+ compareParam={compareParam}
848
1008
  onSelectTrace={handleSelectTrace}
849
1009
  onSelectSpan={handleSelectSpan}
850
- onBackToServices={handleBackToServices}
851
- onBackToTraceList={handleBackToTraceList}
1010
+ onDeselectSpan={handleDeselectSpan}
1011
+ onBack={handleBack}
1012
+ onCompare={handleCompare}
852
1013
  />
853
1014
  )}
854
1015
  {activeTab === "metrics" && <MetricsTab />}