@kopai/ui 0.8.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 (49) hide show
  1. package/dist/index.cjs +2427 -1139
  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 +2376 -1082
  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 +8 -9
  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/OtelTraceDetail.tsx +1 -4
  39. package/src/components/observability/shared/TooltipEntryList.tsx +25 -0
  40. package/src/components/observability/utils/flatten-tree.ts +15 -0
  41. package/src/components/observability/utils/time.ts +9 -0
  42. package/src/hooks/use-kopai-data.test.ts +3 -0
  43. package/src/hooks/use-kopai-data.ts +11 -0
  44. package/src/hooks/use-live-logs.test.ts +3 -0
  45. package/src/lib/__snapshots__/generate-prompt-instructions.test.ts.snap +1 -1
  46. package/src/lib/component-catalog.ts +15 -0
  47. package/src/pages/observability.test.tsx +5 -0
  48. package/src/pages/observability.tsx +314 -235
  49. package/src/providers/kopai-provider.tsx +3 -0
@@ -4,11 +4,14 @@
4
4
  */
5
5
 
6
6
  import { useMemo, useState, useRef, useEffect, useCallback } from "react";
7
- import { useVirtualizer } from "@tanstack/react-virtual";
8
7
  import type { denormalizedSignals } from "@kopai/core";
9
8
  type OtelTracesRow = denormalizedSignals.OtelTracesRow;
10
9
  import type { SpanNode, ParsedTrace } from "../types.js";
11
- import { flattenTree, getAllSpanIds } from "../utils/flatten-tree.js";
10
+ import {
11
+ flattenTree,
12
+ getAllSpanIds,
13
+ spanMatchesSearch,
14
+ } from "../utils/flatten-tree.js";
12
15
  import {
13
16
  calculateRelativeTime,
14
17
  calculateRelativeDuration,
@@ -16,17 +19,32 @@ import {
16
19
  } from "../utils/time.js";
17
20
  import { TraceHeader } from "./TraceHeader.js";
18
21
  import { SpanRow } from "./SpanRow.js";
19
- import { DetailPane } from "./DetailPane/index.js";
22
+ import { SpanDetailInline } from "./SpanDetailInline.js";
20
23
  import { LoadingSkeleton } from "../shared/LoadingSkeleton.js";
21
24
  import { useRegisterShortcuts } from "../../KeyboardShortcuts/index.js";
22
25
  import { TRACE_VIEWER_SHORTCUTS } from "./shortcuts.js";
26
+ import { TimeRuler } from "./TimeRuler.js";
27
+ import { SpanSearch } from "./SpanSearch.js";
28
+ import { ViewTabs, type ViewName } from "./ViewTabs.js";
29
+ import { GraphView } from "./GraphView.js";
30
+ import { StatisticsView } from "./StatisticsView.js";
31
+ import { FlamegraphView } from "./FlamegraphView.js";
32
+ import { Minimap } from "./Minimap.js";
23
33
 
24
34
  export interface TraceTimelineProps {
25
35
  rows: OtelTracesRow[];
26
36
  onSpanClick?: (span: SpanNode) => void;
37
+ onSpanDeselect?: () => void;
27
38
  selectedSpanId?: string;
28
39
  isLoading?: boolean;
29
40
  error?: Error;
41
+ view?: ViewName;
42
+ onViewChange?: (view: ViewName) => void;
43
+ uiFind?: string;
44
+ onUiFindChange?: (value: string) => void;
45
+ viewStart?: number;
46
+ viewEnd?: number;
47
+ onViewRangeChange?: (viewStart: number, viewEnd: number) => void;
30
48
  }
31
49
 
32
50
  /** Transform OtelTracesRow[] to ParsedTrace */
@@ -150,12 +168,30 @@ function isSpanAncestorOf(
150
168
  return false;
151
169
  }
152
170
 
171
+ function collectServices(rootSpans: SpanNode[]): string[] {
172
+ const set = new Set<string>();
173
+ function walk(span: SpanNode) {
174
+ set.add(span.serviceName);
175
+ span.children.forEach(walk);
176
+ }
177
+ rootSpans.forEach(walk);
178
+ return Array.from(set).sort();
179
+ }
180
+
153
181
  export function TraceTimeline({
154
182
  rows,
155
183
  onSpanClick,
184
+ onSpanDeselect,
156
185
  selectedSpanId: externalSelectedSpanId,
157
186
  isLoading,
158
187
  error,
188
+ view: externalView,
189
+ onViewChange,
190
+ uiFind: externalUiFind,
191
+ onUiFindChange,
192
+ viewStart: externalViewStart,
193
+ viewEnd: externalViewEnd,
194
+ onViewRangeChange,
159
195
  }: TraceTimelineProps) {
160
196
  useRegisterShortcuts("trace-viewer", TRACE_VIEWER_SHORTCUTS);
161
197
 
@@ -164,23 +200,39 @@ export function TraceTimeline({
164
200
  string | null
165
201
  >(null);
166
202
  const [hoveredSpanId, setHoveredSpanId] = useState<string | null>(null);
203
+ const [internalView, setInternalView] = useState<ViewName>("timeline");
204
+ const [internalUiFind, setInternalUiFind] = useState("");
205
+ const [currentMatchIndex, setCurrentMatchIndex] = useState(0);
206
+ const [headerCollapsed, setHeaderCollapsed] = useState(false);
207
+ const [internalViewStart, setInternalViewStart] = useState(0);
208
+ const [internalViewEnd, setInternalViewEnd] = useState(1);
209
+
167
210
  const selectedSpanId = externalSelectedSpanId ?? internalSelectedSpanId;
211
+ const viewStart = externalViewStart ?? internalViewStart;
212
+ const viewEnd = externalViewEnd ?? internalViewEnd;
213
+ const activeView = externalView ?? internalView;
214
+ const uiFind = externalUiFind ?? internalUiFind;
168
215
  const scrollRef = useRef<HTMLDivElement>(null);
169
216
  const announcementRef = useRef<HTMLDivElement>(null);
170
217
 
171
218
  const parsedTrace = useMemo(() => buildTrace(rows), [rows]);
172
219
 
220
+ const services = useMemo(
221
+ () => (parsedTrace ? collectServices(parsedTrace.rootSpans) : []),
222
+ [parsedTrace]
223
+ );
224
+
173
225
  const flattenedSpans = useMemo(() => {
174
226
  if (!parsedTrace) return [];
175
227
  return flattenTree(parsedTrace.rootSpans, collapsedIds);
176
228
  }, [parsedTrace, collapsedIds]);
177
229
 
178
- const virtualizer = useVirtualizer({
179
- count: flattenedSpans.length,
180
- getScrollElement: () => scrollRef.current,
181
- estimateSize: () => 32,
182
- overscan: 5,
183
- });
230
+ const matchingIndices = useMemo(() => {
231
+ if (!uiFind) return [];
232
+ return flattenedSpans
233
+ .map((item, idx) => (spanMatchesSearch(item.span, uiFind) ? idx : -1))
234
+ .filter((idx) => idx !== -1);
235
+ }, [flattenedSpans, uiFind]);
184
236
 
185
237
  const handleToggleCollapse = (spanId: string) => {
186
238
  setCollapsedIds((prev) => {
@@ -191,15 +243,25 @@ export function TraceTimeline({
191
243
  });
192
244
  };
193
245
 
246
+ const handleDeselect = useCallback(() => {
247
+ setInternalSelectedSpanId(null);
248
+ onSpanDeselect?.();
249
+ }, [onSpanDeselect]);
250
+
194
251
  const handleSpanClick = useCallback(
195
252
  (span: SpanNode) => {
196
- setInternalSelectedSpanId(span.spanId);
197
- onSpanClick?.(span);
198
- if (announcementRef.current) {
199
- announcementRef.current.textContent = `Selected span: ${span.name}, duration: ${formatDuration(span.durationMs)}`;
253
+ const isAlreadySelected = selectedSpanId === span.spanId;
254
+ if (isAlreadySelected) {
255
+ handleDeselect();
256
+ } else {
257
+ setInternalSelectedSpanId(span.spanId);
258
+ onSpanClick?.(span);
259
+ if (announcementRef.current) {
260
+ announcementRef.current.textContent = `Selected span: ${span.name}, duration: ${formatDuration(span.durationMs)}`;
261
+ }
200
262
  }
201
263
  },
202
- [onSpanClick]
264
+ [onSpanClick, selectedSpanId, handleDeselect]
203
265
  );
204
266
 
205
267
  const handleExpandAll = useCallback(() => {
@@ -259,25 +321,94 @@ export function TraceTimeline({
259
321
  [selectedSpanId, flattenedSpans]
260
322
  );
261
323
 
262
- const handleDeselect = useCallback(() => {
263
- setInternalSelectedSpanId(null);
324
+ const handleViewChange = useCallback(
325
+ (view: ViewName) => {
326
+ if (onViewChange) onViewChange(view);
327
+ else setInternalView(view);
328
+ },
329
+ [onViewChange]
330
+ );
331
+
332
+ const handleUiFindChange = useCallback(
333
+ (value: string) => {
334
+ if (onUiFindChange) onUiFindChange(value);
335
+ else setInternalUiFind(value);
336
+ setCurrentMatchIndex(0);
337
+ },
338
+ [onUiFindChange]
339
+ );
340
+
341
+ const handleViewRangeChange = useCallback(
342
+ (start: number, end: number) => {
343
+ if (onViewRangeChange) onViewRangeChange(start, end);
344
+ else {
345
+ setInternalViewStart(start);
346
+ setInternalViewEnd(end);
347
+ }
348
+ },
349
+ [onViewRangeChange]
350
+ );
351
+
352
+ const scrollToSpan = useCallback((spanId: string) => {
353
+ const el = scrollRef.current?.querySelector(`[data-span-id="${spanId}"]`);
354
+ el?.scrollIntoView({ block: "center", behavior: "smooth" });
264
355
  }, []);
265
356
 
357
+ const handleSearchNext = useCallback(() => {
358
+ if (matchingIndices.length === 0) return;
359
+ const next = (currentMatchIndex + 1) % matchingIndices.length;
360
+ setCurrentMatchIndex(next);
361
+ const idx = matchingIndices[next];
362
+ if (idx !== undefined) {
363
+ const item = flattenedSpans[idx];
364
+ if (item) {
365
+ handleSpanClick(item.span);
366
+ scrollToSpan(item.span.spanId);
367
+ }
368
+ }
369
+ }, [
370
+ matchingIndices,
371
+ currentMatchIndex,
372
+ flattenedSpans,
373
+ handleSpanClick,
374
+ scrollToSpan,
375
+ ]);
376
+
377
+ const handleSearchPrev = useCallback(() => {
378
+ if (matchingIndices.length === 0) return;
379
+ const prev =
380
+ (currentMatchIndex - 1 + matchingIndices.length) % matchingIndices.length;
381
+ setCurrentMatchIndex(prev);
382
+ const idx = matchingIndices[prev];
383
+ if (idx !== undefined) {
384
+ const item = flattenedSpans[idx];
385
+ if (item) {
386
+ handleSpanClick(item.span);
387
+ scrollToSpan(item.span.spanId);
388
+ }
389
+ }
390
+ }, [
391
+ matchingIndices,
392
+ currentMatchIndex,
393
+ flattenedSpans,
394
+ handleSpanClick,
395
+ scrollToSpan,
396
+ ]);
397
+
266
398
  useEffect(() => {
267
399
  if (!selectedSpanId) return;
268
- const selectedIndex = flattenedSpans.findIndex(
269
- (item) => item.span.spanId === selectedSpanId
270
- );
271
- if (selectedIndex !== -1) {
272
- virtualizer.scrollToIndex(selectedIndex, {
273
- align: "center",
274
- behavior: "smooth",
275
- });
276
- }
277
- }, [selectedSpanId, flattenedSpans, virtualizer]);
400
+ scrollToSpan(selectedSpanId);
401
+ }, [selectedSpanId, scrollToSpan]);
278
402
 
279
403
  useEffect(() => {
280
404
  const handleKeyDown = (e: KeyboardEvent) => {
405
+ // Escape always deselects when a span is selected
406
+ if (e.key === "Escape" && selectedSpanId) {
407
+ e.preventDefault();
408
+ handleDeselect();
409
+ return;
410
+ }
411
+
281
412
  const timelineElement = scrollRef.current?.parentElement;
282
413
  if (!timelineElement?.contains(document.activeElement)) return;
283
414
 
@@ -303,8 +434,7 @@ export function TraceTimeline({
303
434
  handleCollapseExpand(false);
304
435
  break;
305
436
  case "Escape":
306
- e.preventDefault();
307
- handleDeselect();
437
+ // handled above, before focus check
308
438
  break;
309
439
  case "Enter": {
310
440
  if (selectedSpanId) {
@@ -378,10 +508,6 @@ export function TraceTimeline({
378
508
  }
379
509
 
380
510
  const totalDurationMs = parsedTrace.maxTimeMs - parsedTrace.minTimeMs;
381
- const selectedSpan =
382
- selectedSpanId && flattenedSpans.length > 0
383
- ? flattenedSpans.find((item) => item.span.spanId === selectedSpanId)?.span
384
- : null;
385
511
 
386
512
  return (
387
513
  <div className="flex h-full bg-background">
@@ -393,86 +519,104 @@ export function TraceTimeline({
393
519
  aria-live="polite"
394
520
  aria-atomic="true"
395
521
  />
396
- <TraceHeader trace={parsedTrace} />
397
- <div
398
- ref={scrollRef}
399
- className="flex-1 overflow-auto outline-none"
400
- role="tree"
401
- aria-label="Trace timeline"
402
- tabIndex={0}
403
- >
404
- <div
405
- style={{
406
- height: `${virtualizer.getTotalSize()}px`,
407
- width: "100%",
408
- position: "relative",
409
- }}
410
- >
411
- {virtualizer.getVirtualItems().map((virtualItem) => {
412
- const item = flattenedSpans[virtualItem.index];
413
- if (!item) return null;
414
-
415
- const { span, level } = item;
416
- const isCollapsed = collapsedIds.has(span.spanId);
417
- const isSelected = span.spanId === selectedSpanId;
418
- const isHovered = span.spanId === hoveredSpanId;
419
- const isParentOfHovered = hoveredSpanId
420
- ? isSpanAncestorOf(span, hoveredSpanId, flattenedSpans)
421
- : false;
422
-
423
- const relativeStart = calculateRelativeTime(
424
- span.startTimeUnixMs,
425
- parsedTrace.minTimeMs,
426
- parsedTrace.maxTimeMs
427
- );
428
- const relativeDuration = calculateRelativeDuration(
429
- span.durationMs,
430
- totalDurationMs
431
- );
432
-
433
- return (
434
- <div
435
- key={span.spanId}
436
- style={{
437
- position: "absolute",
438
- top: 0,
439
- left: 0,
440
- width: "100%",
441
- height: `${virtualItem.size}px`,
442
- transform: `translateY(${virtualItem.start}px)`,
443
- }}
444
- >
445
- <SpanRow
446
- span={span}
447
- level={level}
448
- isCollapsed={isCollapsed}
449
- isSelected={isSelected}
450
- isHovered={isHovered}
451
- isParentOfHovered={isParentOfHovered}
452
- relativeStart={relativeStart}
453
- relativeDuration={relativeDuration}
454
- onClick={() => handleSpanClick(span)}
455
- onToggleCollapse={() => handleToggleCollapse(span.spanId)}
456
- onMouseEnter={() => setHoveredSpanId(span.spanId)}
457
- onMouseLeave={() => setHoveredSpanId(null)}
458
- />
459
- </div>
460
- );
461
- })}
462
- </div>
463
- </div>
464
- </div>
522
+ <TraceHeader
523
+ trace={parsedTrace}
524
+ services={services}
525
+ onHeaderToggle={() => setHeaderCollapsed((p) => !p)}
526
+ isCollapsed={headerCollapsed}
527
+ />
528
+ <ViewTabs activeView={activeView} onChange={handleViewChange} />
529
+ <SpanSearch
530
+ value={uiFind}
531
+ onChange={handleUiFindChange}
532
+ matchCount={matchingIndices.length}
533
+ currentMatch={currentMatchIndex}
534
+ onPrev={handleSearchPrev}
535
+ onNext={handleSearchNext}
536
+ />
465
537
 
466
- {selectedSpan && (
467
- <div className="w-96 h-full flex-shrink-0">
468
- <DetailPane
469
- span={selectedSpan}
470
- onClose={handleDeselect}
471
- // TODO: wire up cross-trace navigation
472
- onLinkClick={undefined}
538
+ {activeView === "graph" ? (
539
+ <GraphView trace={parsedTrace} />
540
+ ) : activeView === "statistics" ? (
541
+ <StatisticsView trace={parsedTrace} />
542
+ ) : activeView === "flamegraph" ? (
543
+ <FlamegraphView
544
+ trace={parsedTrace}
545
+ onSpanClick={handleSpanClick}
546
+ selectedSpanId={selectedSpanId ?? undefined}
473
547
  />
474
- </div>
475
- )}
548
+ ) : (
549
+ <>
550
+ <Minimap
551
+ trace={parsedTrace}
552
+ viewStart={viewStart}
553
+ viewEnd={viewEnd}
554
+ onViewChange={handleViewRangeChange}
555
+ />
556
+ <TimeRuler
557
+ totalDurationMs={totalDurationMs * (viewEnd - viewStart)}
558
+ leftColumnWidth="24rem"
559
+ offsetMs={totalDurationMs * viewStart}
560
+ />
561
+ <div
562
+ ref={scrollRef}
563
+ className="flex-1 overflow-auto outline-none"
564
+ role="tree"
565
+ aria-label="Trace timeline"
566
+ tabIndex={0}
567
+ >
568
+ {flattenedSpans.map((item) => {
569
+ const { span, level } = item;
570
+ const isCollapsed = collapsedIds.has(span.spanId);
571
+ const isSelected = span.spanId === selectedSpanId;
572
+ const isHovered = span.spanId === hoveredSpanId;
573
+ const isParentOfHovered = hoveredSpanId
574
+ ? isSpanAncestorOf(span, hoveredSpanId, flattenedSpans)
575
+ : false;
576
+
577
+ const viewRange = viewEnd - viewStart;
578
+ const relativeStart =
579
+ (calculateRelativeTime(
580
+ span.startTimeUnixMs,
581
+ parsedTrace.minTimeMs,
582
+ parsedTrace.maxTimeMs
583
+ ) -
584
+ viewStart) /
585
+ viewRange;
586
+ const relativeDuration =
587
+ calculateRelativeDuration(span.durationMs, totalDurationMs) /
588
+ viewRange;
589
+
590
+ return (
591
+ <div key={span.spanId} data-span-id={span.spanId}>
592
+ <SpanRow
593
+ span={span}
594
+ level={level}
595
+ isCollapsed={isCollapsed}
596
+ isSelected={isSelected}
597
+ isHovered={isHovered}
598
+ isParentOfHovered={isParentOfHovered}
599
+ relativeStart={relativeStart}
600
+ relativeDuration={relativeDuration}
601
+ onClick={() => handleSpanClick(span)}
602
+ onToggleCollapse={() => handleToggleCollapse(span.spanId)}
603
+ onMouseEnter={() => setHoveredSpanId(span.spanId)}
604
+ onMouseLeave={() => setHoveredSpanId(null)}
605
+ uiFind={uiFind || undefined}
606
+ />
607
+ {isSelected && (
608
+ <SpanDetailInline
609
+ span={span}
610
+ traceStartMs={parsedTrace.minTimeMs}
611
+ />
612
+ )}
613
+ </div>
614
+ );
615
+ })}
616
+ </div>
617
+ </>
618
+ )}
619
+ </div>
476
620
  </div>
477
621
  );
478
622
  }
@@ -12,6 +12,18 @@ export type {
12
12
  TraceSummary,
13
13
  } from "./TraceSearch/index.js";
14
14
 
15
+ export { SearchForm } from "./TraceSearch/SearchForm.js";
16
+ export type { SearchFormProps } from "./TraceSearch/SearchForm.js";
17
+
18
+ export { ScatterPlot } from "./TraceSearch/ScatterPlot.js";
19
+ export type { ScatterPlotProps } from "./TraceSearch/ScatterPlot.js";
20
+
21
+ export { SortDropdown } from "./TraceSearch/SortDropdown.js";
22
+ export type { SortDropdownProps } from "./TraceSearch/SortDropdown.js";
23
+
24
+ export { DurationBar } from "./TraceSearch/DurationBar.js";
25
+ export type { DurationBarProps } from "./TraceSearch/DurationBar.js";
26
+
15
27
  export { TraceDetail } from "./TraceDetail/index.js";
16
28
  export type { TraceDetailProps } from "./TraceDetail/index.js";
17
29
 
@@ -54,6 +66,9 @@ export type {
54
66
  export { DynamicDashboard } from "./DynamicDashboard/index.js";
55
67
  export type { DynamicDashboardProps } from "./DynamicDashboard/index.js";
56
68
 
69
+ export { TraceComparison } from "./TraceComparison/index.js";
70
+ export type { TraceComparisonProps } from "./TraceComparison/index.js";
71
+
57
72
  // Types
58
73
  export type {
59
74
  SpanNode,
@@ -18,16 +18,13 @@ export function OtelTraceDetail(props: Props) {
18
18
 
19
19
  const response = props.data as { data?: OtelTracesRow[] } | null;
20
20
  const rows = response?.data ?? [];
21
- const firstRow = rows[0];
22
- const service = firstRow?.ServiceName ?? "unknown";
23
- const traceId = firstRow?.TraceId ?? "";
21
+ const traceId = rows[0]?.TraceId ?? "";
24
22
 
25
23
  return (
26
24
  <TraceDetail
27
25
  rows={rows}
28
26
  isLoading={props.loading}
29
27
  error={props.error ?? undefined}
30
- service={service}
31
28
  traceId={traceId}
32
29
  onBack={() => {}}
33
30
  />
@@ -0,0 +1,25 @@
1
+ import type { TooltipPayload } from "recharts";
2
+
3
+ export function TooltipEntryList({
4
+ payload,
5
+ displayLabelMap,
6
+ formatValue,
7
+ }: {
8
+ payload: TooltipPayload;
9
+ displayLabelMap: Map<string, string>;
10
+ formatValue: (v: number) => string;
11
+ }) {
12
+ return payload.map((entry, i) => {
13
+ const dataKey = entry.dataKey;
14
+ const value = entry.value;
15
+ if (typeof dataKey !== "string" || typeof value !== "number") return null;
16
+ return (
17
+ <p key={i} className="text-sm" style={{ color: entry.color }}>
18
+ <span className="font-medium">
19
+ {displayLabelMap.get(dataKey) ?? dataKey}:
20
+ </span>{" "}
21
+ {formatValue(value)}
22
+ </p>
23
+ );
24
+ });
25
+ }
@@ -40,6 +40,21 @@ export function getAllDescendantIds(span: SpanNode): string[] {
40
40
  return ids;
41
41
  }
42
42
 
43
+ /** Flatten all spans (ignoring collapse state) with depth. */
44
+ export function flattenAllSpans(rootSpans: SpanNode[]): FlattenedSpan[] {
45
+ return flattenTree(rootSpans, new Set());
46
+ }
47
+
48
+ export function spanMatchesSearch(span: SpanNode, query: string): boolean {
49
+ const q = query.toLowerCase();
50
+ if (span.name.toLowerCase().includes(q)) return true;
51
+ if (span.serviceName.toLowerCase().includes(q)) return true;
52
+ for (const val of Object.values(span.attributes)) {
53
+ if (String(val).toLowerCase().includes(q)) return true;
54
+ }
55
+ return false;
56
+ }
57
+
43
58
  export function getAllSpanIds(rootSpans: SpanNode[]): string[] {
44
59
  const ids: string[] = [];
45
60
 
@@ -27,6 +27,15 @@ export function formatTimestamp(timestampMs: number): string {
27
27
  });
28
28
  }
29
29
 
30
+ export function formatRelativeTime(
31
+ eventTimeMs: number,
32
+ spanStartMs: number
33
+ ): string {
34
+ const relativeMs = eventTimeMs - spanStartMs;
35
+ const prefix = relativeMs < 0 ? "-" : "+";
36
+ return `${prefix}${formatDuration(Math.abs(relativeMs))}`;
37
+ }
38
+
30
39
  export function calculateRelativeTime(
31
40
  timeMs: number,
32
41
  minTimeMs: number,
@@ -19,6 +19,9 @@ const createMockClient = () => ({
19
19
  getTrace: vi.fn(),
20
20
  discoverMetrics: vi.fn(),
21
21
  getDashboard: vi.fn(),
22
+ getServices: vi.fn(),
23
+ getOperations: vi.fn(),
24
+ searchTraceSummariesPage: vi.fn(),
22
25
  });
23
26
 
24
27
  type MockClient = ReturnType<typeof createMockClient>;
@@ -34,6 +34,17 @@ function fetchForDataSource(
34
34
  return client.getTrace(dataSource.params.traceId, { signal });
35
35
  case "discoverMetrics":
36
36
  return client.discoverMetrics({ signal });
37
+ case "getServices":
38
+ return client.getServices({ signal });
39
+ case "getOperations":
40
+ return client.getOperations(dataSource.params.serviceName, { signal });
41
+ case "searchTraceSummariesPage":
42
+ return client.searchTraceSummariesPage(
43
+ dataSource.params as Parameters<
44
+ typeof client.searchTraceSummariesPage
45
+ >[0],
46
+ { signal }
47
+ );
37
48
  default: {
38
49
  const exhaustiveCheck: never = dataSource;
39
50
  throw new Error(
@@ -22,6 +22,9 @@ const createMockClient = () => ({
22
22
  getTrace: vi.fn(),
23
23
  discoverMetrics: vi.fn(),
24
24
  getDashboard: vi.fn(),
25
+ getServices: vi.fn(),
26
+ getOperations: vi.fn(),
27
+ searchTraceSummariesPage: vi.fn(),
25
28
  });
26
29
 
27
30
  function wrapper(client: KopaiClient) {