@workflow/web-shared 5.0.0-beta.8 → 5.0.0-beta.9

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 (32) hide show
  1. package/dist/components/index.d.ts +1 -0
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +2 -1
  4. package/dist/components/new-trace-viewer/components/event-list.d.ts +3 -1
  5. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  6. package/dist/components/new-trace-viewer/components/event-list.js +8 -7
  7. package/dist/components/new-trace-viewer/components/timeline.d.ts +4 -1
  8. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  9. package/dist/components/new-trace-viewer/components/timeline.js +6 -5
  10. package/dist/components/new-trace-viewer/detail-panel.d.ts.map +1 -1
  11. package/dist/components/new-trace-viewer/detail-panel.js +3 -2
  12. package/dist/components/new-trace-viewer/search.d.ts +19 -0
  13. package/dist/components/new-trace-viewer/search.d.ts.map +1 -0
  14. package/dist/components/new-trace-viewer/search.js +134 -0
  15. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  16. package/dist/components/new-trace-viewer/trace-viewer.js +23 -18
  17. package/dist/components/ui/button.d.ts.map +1 -1
  18. package/dist/components/ui/button.js +10 -3
  19. package/dist/components/ui/icon-button.d.ts +13 -0
  20. package/dist/components/ui/icon-button.d.ts.map +1 -0
  21. package/dist/components/ui/icon-button.js +29 -0
  22. package/dist/styles.css +5 -0
  23. package/package.json +2 -2
  24. package/src/components/index.ts +1 -0
  25. package/src/components/new-trace-viewer/components/event-list.tsx +16 -5
  26. package/src/components/new-trace-viewer/components/timeline.tsx +11 -2
  27. package/src/components/new-trace-viewer/detail-panel.tsx +3 -6
  28. package/src/components/new-trace-viewer/search.ts +178 -0
  29. package/src/components/new-trace-viewer/trace-viewer.tsx +53 -46
  30. package/src/components/ui/button.tsx +9 -2
  31. package/src/components/ui/icon-button.tsx +57 -0
  32. package/src/styles.css +5 -0
@@ -0,0 +1,13 @@
1
+ import { type VariantProps } from 'class-variance-authority';
2
+ import type * as React from 'react';
3
+ declare const iconButtonVariants: (props?: ({
4
+ variant?: "muted" | "tertiary" | null | undefined;
5
+ size?: "small" | "tiny" | null | undefined;
6
+ } & import("class-variance-authority/types").ClassProp) | undefined) => string;
7
+ export type IconButtonProps = Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'type'> & VariantProps<typeof iconButtonVariants> & {
8
+ 'aria-label': string;
9
+ type?: 'button' | 'submit' | 'reset';
10
+ };
11
+ export declare function IconButton({ className, variant, size, type, ...props }: IconButtonProps): import("react/jsx-runtime").JSX.Element;
12
+ export { iconButtonVariants };
13
+ //# sourceMappingURL=icon-button.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"icon-button.d.ts","sourceRoot":"","sources":["../../../src/components/ui/icon-button.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAO,KAAK,YAAY,EAAE,MAAM,0BAA0B,CAAC;AAClE,OAAO,KAAK,KAAK,KAAK,MAAM,OAAO,CAAC;AAGpC,QAAA,MAAM,kBAAkB;;;8EAyBvB,CAAC;AAEF,MAAM,MAAM,eAAe,GAAG,IAAI,CAChC,KAAK,CAAC,oBAAoB,CAAC,iBAAiB,CAAC,EAC7C,MAAM,CACP,GACC,YAAY,CAAC,OAAO,kBAAkB,CAAC,GAAG;IACxC,YAAY,EAAE,MAAM,CAAC;IACrB,IAAI,CAAC,EAAE,QAAQ,GAAG,QAAQ,GAAG,OAAO,CAAC;CACtC,CAAC;AAEJ,wBAAgB,UAAU,CAAC,EACzB,SAAS,EACT,OAAO,EACP,IAAI,EACJ,IAAe,EACf,GAAG,KAAK,EACT,EAAE,eAAe,2CAQjB;AAED,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
@@ -0,0 +1,29 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { cva } from 'class-variance-authority';
3
+ import { cn } from '../../lib/utils';
4
+ const iconButtonVariants = cva([
5
+ 'm-0 inline-flex shrink-0 appearance-none items-center justify-center border-0 bg-transparent p-0 align-baseline font-inherit no-underline [background:none] [-webkit-appearance:none] [-webkit-tap-highlight-color:transparent]',
6
+ 'transition-colors duration-150 ease-in-out',
7
+ 'enabled:cursor-pointer disabled:cursor-not-allowed disabled:opacity-40',
8
+ 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--ds-focus-color)] focus-visible:outline-offset-2',
9
+ ], {
10
+ variants: {
11
+ variant: {
12
+ tertiary: 'text-gray-900 enabled:hover:bg-gray-alpha-200 enabled:hover:text-gray-1000 enabled:active:bg-gray-alpha-200 focus-visible:text-gray-1000',
13
+ muted: 'text-gray-900 enabled:hover:bg-gray-alpha-200 enabled:hover:text-gray-1000 enabled:active:bg-gray-alpha-200 focus-visible:text-gray-1000',
14
+ },
15
+ size: {
16
+ tiny: 'h-6 w-6 rounded-[4px] [&_svg]:h-4 [&_svg]:w-4',
17
+ small: 'h-8 w-8 rounded-md [&_svg]:h-4 [&_svg]:w-4',
18
+ },
19
+ },
20
+ defaultVariants: {
21
+ variant: 'tertiary',
22
+ size: 'tiny',
23
+ },
24
+ });
25
+ export function IconButton({ className, variant, size, type = 'button', ...props }) {
26
+ return (_jsx("button", { type: type, className: cn(iconButtonVariants({ variant, size, className })), ...props }));
27
+ }
28
+ export { iconButtonVariants };
29
+ //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoiaWNvbi1idXR0b24uanMiLCJzb3VyY2VSb290IjoiIiwic291cmNlcyI6WyIuLi8uLi8uLi9zcmMvY29tcG9uZW50cy91aS9pY29uLWJ1dHRvbi50c3giXSwibmFtZXMiOltdLCJtYXBwaW5ncyI6IjtBQUFBLE9BQU8sRUFBRSxHQUFHLEVBQXFCLE1BQU0sMEJBQTBCLENBQUM7QUFFbEUsT0FBTyxFQUFFLEVBQUUsRUFBRSxNQUFNLGlCQUFpQixDQUFDO0FBRXJDLE1BQU0sa0JBQWtCLEdBQUcsR0FBRyxDQUM1QjtJQUNFLGlPQUFpTztJQUNqTyw0Q0FBNEM7SUFDNUMsd0VBQXdFO0lBQ3hFLDRIQUE0SDtDQUM3SCxFQUNEO0lBQ0UsUUFBUSxFQUFFO1FBQ1IsT0FBTyxFQUFFO1lBQ1AsUUFBUSxFQUNOLDBJQUEwSTtZQUM1SSxLQUFLLEVBQ0gsMElBQTBJO1NBQzdJO1FBQ0QsSUFBSSxFQUFFO1lBQ0osSUFBSSxFQUFFLCtDQUErQztZQUNyRCxLQUFLLEVBQUUsNENBQTRDO1NBQ3BEO0tBQ0Y7SUFDRCxlQUFlLEVBQUU7UUFDZixPQUFPLEVBQUUsVUFBVTtRQUNuQixJQUFJLEVBQUUsTUFBTTtLQUNiO0NBQ0YsQ0FDRixDQUFDO0FBV0YsTUFBTSxVQUFVLFVBQVUsQ0FBQyxFQUN6QixTQUFTLEVBQ1QsT0FBTyxFQUNQLElBQUksRUFDSixJQUFJLEdBQUcsUUFBUSxFQUNmLEdBQUcsS0FBSyxFQUNRO0lBQ2hCLE9BQU8sQ0FDTCxpQkFDRSxJQUFJLEVBQUUsSUFBSSxFQUNWLFNBQVMsRUFBRSxFQUFFLENBQUMsa0JBQWtCLENBQUMsRUFBRSxPQUFPLEVBQUUsSUFBSSxFQUFFLFNBQVMsRUFBRSxDQUFDLENBQUMsS0FDM0QsS0FBSyxHQUNULENBQ0gsQ0FBQztBQUNKLENBQUM7QUFFRCxPQUFPLEVBQUUsa0JBQWtCLEVBQUUsQ0FBQyIsInNvdXJjZXNDb250ZW50IjpbImltcG9ydCB7IGN2YSwgdHlwZSBWYXJpYW50UHJvcHMgfSBmcm9tICdjbGFzcy12YXJpYW5jZS1hdXRob3JpdHknO1xuaW1wb3J0IHR5cGUgKiBhcyBSZWFjdCBmcm9tICdyZWFjdCc7XG5pbXBvcnQgeyBjbiB9IGZyb20gJy4uLy4uL2xpYi91dGlscyc7XG5cbmNvbnN0IGljb25CdXR0b25WYXJpYW50cyA9IGN2YShcbiAgW1xuICAgICdtLTAgaW5saW5lLWZsZXggc2hyaW5rLTAgYXBwZWFyYW5jZS1ub25lIGl0ZW1zLWNlbnRlciBqdXN0aWZ5LWNlbnRlciBib3JkZXItMCBiZy10cmFuc3BhcmVudCBwLTAgYWxpZ24tYmFzZWxpbmUgZm9udC1pbmhlcml0IG5vLXVuZGVybGluZSBbYmFja2dyb3VuZDpub25lXSBbLXdlYmtpdC1hcHBlYXJhbmNlOm5vbmVdIFstd2Via2l0LXRhcC1oaWdobGlnaHQtY29sb3I6dHJhbnNwYXJlbnRdJyxcbiAgICAndHJhbnNpdGlvbi1jb2xvcnMgZHVyYXRpb24tMTUwIGVhc2UtaW4tb3V0JyxcbiAgICAnZW5hYmxlZDpjdXJzb3ItcG9pbnRlciBkaXNhYmxlZDpjdXJzb3Itbm90LWFsbG93ZWQgZGlzYWJsZWQ6b3BhY2l0eS00MCcsXG4gICAgJ2ZvY3VzLXZpc2libGU6b3V0bGluZSBmb2N1cy12aXNpYmxlOm91dGxpbmUtMiBmb2N1cy12aXNpYmxlOm91dGxpbmUtW3ZhcigtLWRzLWZvY3VzLWNvbG9yKV0gZm9jdXMtdmlzaWJsZTpvdXRsaW5lLW9mZnNldC0yJyxcbiAgXSxcbiAge1xuICAgIHZhcmlhbnRzOiB7XG4gICAgICB2YXJpYW50OiB7XG4gICAgICAgIHRlcnRpYXJ5OlxuICAgICAgICAgICd0ZXh0LWdyYXktOTAwIGVuYWJsZWQ6aG92ZXI6YmctZ3JheS1hbHBoYS0yMDAgZW5hYmxlZDpob3Zlcjp0ZXh0LWdyYXktMTAwMCBlbmFibGVkOmFjdGl2ZTpiZy1ncmF5LWFscGhhLTIwMCBmb2N1cy12aXNpYmxlOnRleHQtZ3JheS0xMDAwJyxcbiAgICAgICAgbXV0ZWQ6XG4gICAgICAgICAgJ3RleHQtZ3JheS05MDAgZW5hYmxlZDpob3ZlcjpiZy1ncmF5LWFscGhhLTIwMCBlbmFibGVkOmhvdmVyOnRleHQtZ3JheS0xMDAwIGVuYWJsZWQ6YWN0aXZlOmJnLWdyYXktYWxwaGEtMjAwIGZvY3VzLXZpc2libGU6dGV4dC1ncmF5LTEwMDAnLFxuICAgICAgfSxcbiAgICAgIHNpemU6IHtcbiAgICAgICAgdGlueTogJ2gtNiB3LTYgcm91bmRlZC1bNHB4XSBbJl9zdmddOmgtNCBbJl9zdmddOnctNCcsXG4gICAgICAgIHNtYWxsOiAnaC04IHctOCByb3VuZGVkLW1kIFsmX3N2Z106aC00IFsmX3N2Z106dy00JyxcbiAgICAgIH0sXG4gICAgfSxcbiAgICBkZWZhdWx0VmFyaWFudHM6IHtcbiAgICAgIHZhcmlhbnQ6ICd0ZXJ0aWFyeScsXG4gICAgICBzaXplOiAndGlueScsXG4gICAgfSxcbiAgfVxuKTtcblxuZXhwb3J0IHR5cGUgSWNvbkJ1dHRvblByb3BzID0gT21pdDxcbiAgUmVhY3QuQnV0dG9uSFRNTEF0dHJpYnV0ZXM8SFRNTEJ1dHRvbkVsZW1lbnQ+LFxuICAndHlwZSdcbj4gJlxuICBWYXJpYW50UHJvcHM8dHlwZW9mIGljb25CdXR0b25WYXJpYW50cz4gJiB7XG4gICAgJ2FyaWEtbGFiZWwnOiBzdHJpbmc7XG4gICAgdHlwZT86ICdidXR0b24nIHwgJ3N1Ym1pdCcgfCAncmVzZXQnO1xuICB9O1xuXG5leHBvcnQgZnVuY3Rpb24gSWNvbkJ1dHRvbih7XG4gIGNsYXNzTmFtZSxcbiAgdmFyaWFudCxcbiAgc2l6ZSxcbiAgdHlwZSA9ICdidXR0b24nLFxuICAuLi5wcm9wc1xufTogSWNvbkJ1dHRvblByb3BzKSB7XG4gIHJldHVybiAoXG4gICAgPGJ1dHRvblxuICAgICAgdHlwZT17dHlwZX1cbiAgICAgIGNsYXNzTmFtZT17Y24oaWNvbkJ1dHRvblZhcmlhbnRzKHsgdmFyaWFudCwgc2l6ZSwgY2xhc3NOYW1lIH0pKX1cbiAgICAgIHsuLi5wcm9wc31cbiAgICAvPlxuICApO1xufVxuXG5leHBvcnQgeyBpY29uQnV0dG9uVmFyaWFudHMgfTtcbiJdfQ==
package/dist/styles.css CHANGED
@@ -36,6 +36,9 @@
36
36
  --ds-gray-alpha-700: hsla(0, 0%, 0%, 0.44);
37
37
  --ds-gray-alpha-1000: hsla(0, 0%, 0%, 0.91);
38
38
 
39
+ /* Focus */
40
+ --ds-focus-color: var(--ds-blue-700);
41
+
39
42
  /* Blue */
40
43
  --ds-blue-100: hsl(212, 100%, 97%);
41
44
  --ds-blue-200: hsl(210, 100%, 96%);
@@ -126,6 +129,8 @@
126
129
  --ds-gray-alpha-700: rgba(255, 255, 255, 0.45);
127
130
  --ds-gray-alpha-1000: rgba(255, 255, 255, 0.92);
128
131
 
132
+ --ds-focus-color: var(--ds-blue-900);
133
+
129
134
  --ds-blue-100: hsl(216, 50%, 12%);
130
135
  --ds-blue-200: hsl(214, 59%, 15%);
131
136
  --ds-blue-300: hsl(213, 71%, 20%);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@workflow/web-shared",
3
3
  "description": "Shared components for Workflow Observability UI",
4
- "version": "5.0.0-beta.8",
4
+ "version": "5.0.0-beta.9",
5
5
  "private": false,
6
6
  "files": [
7
7
  "dist",
@@ -53,7 +53,7 @@
53
53
  "streamdown": "2.3.0",
54
54
  "tailwind-merge": "3.5.0",
55
55
  "tailwindcss": "4",
56
- "@workflow/core": "5.0.0-beta.8",
56
+ "@workflow/core": "5.0.0-beta.9",
57
57
  "@workflow/utils": "5.0.0-beta.3",
58
58
  "@workflow/world": "5.0.0-beta.5"
59
59
  },
@@ -32,6 +32,7 @@ export {
32
32
  type DecryptClickContextValue,
33
33
  } from './ui/data-inspector';
34
34
  export { DecryptButton } from './ui/decrypt-button';
35
+ export { IconButton } from './ui/icon-button';
35
36
  export { LoadMoreButton } from './ui/load-more-button';
36
37
  export { MenuDropdown, type MenuDropdownOption } from './ui/menu-dropdown';
37
38
  export { Spinner } from './ui/spinner';
@@ -3,11 +3,12 @@ import { cn } from '../../../lib/utils';
3
3
  import type { Span } from '../../trace-viewer/types';
4
4
  import { formatDuration } from '../../trace-viewer/util/timing';
5
5
  import {
6
- WorkflowIcon,
7
- WebhookIcon,
8
6
  SleepIcon,
9
7
  StepForwardIcon,
8
+ WebhookIcon,
9
+ WorkflowIcon,
10
10
  } from '../icons';
11
+ import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
11
12
  import { getSpanDurationMs } from '../utils';
12
13
  import { MiddleTruncate } from './middle-truncate/middle-truncate';
13
14
 
@@ -41,10 +42,12 @@ function getEventStyle(resource: string, isErrored: boolean): EventStyle {
41
42
  const EventRow = ({
42
43
  span,
43
44
  isSelected,
45
+ isDimmed,
44
46
  onSelectSpan,
45
47
  }: {
46
48
  span: Span;
47
49
  isSelected: boolean;
50
+ isDimmed?: boolean;
48
51
  onSelectSpan: (spanId: string) => void;
49
52
  }) => {
50
53
  const durationMs = getSpanDurationMs(span);
@@ -58,8 +61,9 @@ const EventRow = ({
58
61
  return (
59
62
  <li
60
63
  className={cn(
61
- 'relative overflow-clip group after:absolute after:inset-x-0 after:bottom-0 after:h-px after:bg-gray-alpha-400',
62
- ROW_HEIGHT_CLASS
64
+ 'relative overflow-clip group transition-opacity',
65
+ ROW_HEIGHT_CLASS,
66
+ isDimmed && 'opacity-35'
63
67
  )}
64
68
  role="treeitem"
65
69
  aria-selected={isSelected}
@@ -91,20 +95,27 @@ const EventRow = ({
91
95
  const EventList = ({
92
96
  spans,
93
97
  activeSpanId,
98
+ searchResult,
94
99
  onSelectSpan,
95
100
  }: {
96
101
  spans: Span[];
97
102
  activeSpanId: string | null;
103
+ searchResult: SpanSearchResult;
98
104
  onSelectSpan: (spanId: string) => void;
99
105
  }) => {
100
106
  return (
101
- <ul id="event-list" role="tree" className="block min-h-0 overflow-visible">
107
+ <ul
108
+ id="event-list"
109
+ role="tree"
110
+ className="block min-h-0 overflow-visible divide-y divide-gray-alpha-400 border-b border-gray-alpha-400"
111
+ >
102
112
  {spans.map((span) => {
103
113
  return (
104
114
  <EventRow
105
115
  key={span.spanId}
106
116
  span={span}
107
117
  isSelected={span.spanId === activeSpanId}
118
+ isDimmed={isSpanDimmedBySearch(span.spanId, searchResult)}
108
119
  onSelectSpan={onSelectSpan}
109
120
  />
110
121
  );
@@ -7,6 +7,7 @@ import { cn } from '../../../lib/utils';
7
7
  import type { Span } from '../../trace-viewer/types';
8
8
  import { formatDuration, getHighResInMs } from '../../trace-viewer/util/timing';
9
9
  import type { Segment, SegmentStatus, TimeMarker } from '../utils';
10
+ import { isSpanDimmedBySearch, type SpanSearchResult } from '../search';
10
11
  import {
11
12
  computeSpanGaps,
12
13
  computeSpanSegments,
@@ -264,6 +265,7 @@ const TimelineBar = memo(function TimelineBar({
264
265
  viewDuration,
265
266
  containerWidth,
266
267
  isSelected,
268
+ isDimmed,
267
269
  onSelect,
268
270
  }: {
269
271
  span: Span;
@@ -271,6 +273,7 @@ const TimelineBar = memo(function TimelineBar({
271
273
  viewDuration: number;
272
274
  containerWidth: number;
273
275
  isSelected: boolean;
276
+ isDimmed?: boolean;
274
277
  onSelect: (spanId: string) => void;
275
278
  }): ReactNode {
276
279
  const startMs = getHighResInMs(span.startTime);
@@ -323,12 +326,15 @@ const TimelineBar = memo(function TimelineBar({
323
326
  aria-selected={isSelected}
324
327
  aria-expanded={isSelected}
325
328
  aria-level={1}
326
- className="group/timeline-row h-10 relative flex items-center hover:bg-gray-100 aria-selected:bg-gray-100 aria-selected:hover:bg-gray-200"
329
+ className={cn(
330
+ 'group/timeline-row h-10 relative flex items-center hover:bg-gray-100 aria-selected:bg-gray-100 aria-selected:hover:bg-gray-200 transition-opacity',
331
+ isDimmed && 'opacity-35'
332
+ )}
327
333
  onClick={handleClick}
328
334
  >
329
335
  <div className="absolute inset-y-0" style={TIMELINE_INSET_STYLE}>
330
336
  <div
331
- className="absolute top-1/2 h-6 -translate-y-1/2"
337
+ className="absolute top-1/2 h-6 -translate-y-1/2 rounded-[0.25rem]"
332
338
  style={getBarPositionStyle(geometry)}
333
339
  >
334
340
  {geometry.mode.kind === 'arrow' ? (
@@ -448,6 +454,7 @@ export function Timeline({
448
454
  viewEnd,
449
455
  markers,
450
456
  selectedId,
457
+ searchResult,
451
458
  onSelect,
452
459
  hoverFraction,
453
460
  altHeld = false,
@@ -457,6 +464,7 @@ export function Timeline({
457
464
  viewEnd: number;
458
465
  markers: TimeMarker[];
459
466
  selectedId: string | null;
467
+ searchResult: SpanSearchResult;
460
468
  onSelect: (spanId: string) => void;
461
469
  hoverFraction?: number | null;
462
470
  altHeld?: boolean;
@@ -518,6 +526,7 @@ export function Timeline({
518
526
  viewDuration={viewDuration}
519
527
  containerWidth={timelineWidth}
520
528
  isSelected={selectedId === span.spanId}
529
+ isDimmed={isSpanDimmedBySearch(span.spanId, searchResult)}
521
530
  onSelect={onSelect}
522
531
  />
523
532
  ))}
@@ -4,6 +4,7 @@ import { X } from 'lucide-react';
4
4
  import type { ReactNode } from 'react';
5
5
  import type { Span } from '../trace-viewer/types';
6
6
  import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
7
+ import { IconButton } from '../ui/icon-button';
7
8
  import { getSpanDurationMs } from './utils';
8
9
 
9
10
  interface DetailPanelProps {
@@ -27,13 +28,9 @@ export function DetailPanel({
27
28
  <span className="text-sm font-medium text-gray-1000 truncate">
28
29
  {span.name}
29
30
  </span>
30
- <button
31
- type="button"
32
- className="p-1 rounded-md text-gray-1000 hover:bg-gray-alpha-100 transition-colors"
33
- onClick={onClose}
34
- >
31
+ <IconButton aria-label="Close span details" onClick={onClose}>
35
32
  <X className="w-4 h-4" />
36
- </button>
33
+ </IconButton>
37
34
  </div>
38
35
  <div className="overflow-y-auto p-3 space-y-3">
39
36
  <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1.5 text-sm">
@@ -0,0 +1,178 @@
1
+ import type { Span } from '../trace-viewer/types';
2
+
3
+ const ATTR_FILTER_REGEX = /(?:^|\s)(?<pair>(?<key>[\w.]+):(?<value>\S*))/g;
4
+
5
+ export interface SpanAttributeFilter {
6
+ key: string;
7
+ value: string;
8
+ }
9
+
10
+ export interface ParsedSpanSearchQuery {
11
+ text: string;
12
+ attributes: SpanAttributeFilter[];
13
+ }
14
+
15
+ export interface SpanSearchResult {
16
+ isActive: boolean;
17
+ matchedSpanIds: Set<string>;
18
+ matchingSpans: Span[];
19
+ query: ParsedSpanSearchQuery;
20
+ }
21
+
22
+ export function parseSpanSearchQuery(rawQuery: string): ParsedSpanSearchQuery {
23
+ const attributes: SpanAttributeFilter[] = [];
24
+ for (const match of rawQuery.matchAll(ATTR_FILTER_REGEX)) {
25
+ const key = match.groups?.key?.trim();
26
+ if (!key) continue;
27
+ attributes.push({
28
+ key,
29
+ value: (match.groups?.value ?? '').toLocaleLowerCase(),
30
+ });
31
+ }
32
+
33
+ const text = rawQuery
34
+ .replace(ATTR_FILTER_REGEX, ' ')
35
+ .replace(/\s{2,}/g, ' ')
36
+ .trim()
37
+ .toLocaleLowerCase();
38
+
39
+ return { text, attributes };
40
+ }
41
+
42
+ export function isSpanDimmedBySearch(
43
+ spanId: string,
44
+ result: SpanSearchResult
45
+ ): boolean {
46
+ return result.isActive && !result.matchedSpanIds.has(spanId);
47
+ }
48
+
49
+ export function searchSpans(spans: Span[], rawQuery: string): SpanSearchResult {
50
+ const query = parseSpanSearchQuery(rawQuery);
51
+ const isActive = Boolean(query.text || query.attributes.length);
52
+ const matchingSpans = isActive
53
+ ? spans.filter((span) => spanMatchesQuery(span, query))
54
+ : spans;
55
+
56
+ return {
57
+ isActive,
58
+ matchedSpanIds: new Set(matchingSpans.map((span) => span.spanId)),
59
+ matchingSpans,
60
+ query,
61
+ };
62
+ }
63
+
64
+ function spanMatchesQuery(span: Span, query: ParsedSpanSearchQuery): boolean {
65
+ if (query.text && !spanMatchesText(span, query.text)) {
66
+ return false;
67
+ }
68
+
69
+ return query.attributes.every(({ key, value }) =>
70
+ spanMatchesAttribute(span, key, value)
71
+ );
72
+ }
73
+
74
+ function spanMatchesText(span: Span, text: string): boolean {
75
+ return [
76
+ span.name,
77
+ span.spanId,
78
+ span.resource,
79
+ span.library.name,
80
+ span.library.version,
81
+ ]
82
+ .filter((value): value is string => typeof value === 'string')
83
+ .some((value) => value.toLocaleLowerCase().includes(text));
84
+ }
85
+
86
+ function spanMatchesAttribute(
87
+ span: Span,
88
+ key: string,
89
+ expectedValue: string
90
+ ): boolean {
91
+ const candidates = getSearchValues(span, key);
92
+ if (!candidates.length) return false;
93
+
94
+ if (!expectedValue) {
95
+ return candidates.some((value) => value != null);
96
+ }
97
+
98
+ return candidates.some((value) =>
99
+ valueToSearchString(value).toLocaleLowerCase().includes(expectedValue)
100
+ );
101
+ }
102
+
103
+ function getSearchValues(span: Span, key: string): unknown[] {
104
+ const candidates: unknown[] = [];
105
+ const normalizedKey = key.toLocaleLowerCase();
106
+
107
+ switch (normalizedKey) {
108
+ case 'id':
109
+ case 'spanid':
110
+ case 'span.id':
111
+ candidates.push(span.spanId);
112
+ break;
113
+ case 'name':
114
+ candidates.push(span.name);
115
+ break;
116
+ case 'resource':
117
+ candidates.push(span.resource);
118
+ break;
119
+ case 'library':
120
+ case 'library.name':
121
+ candidates.push(span.library.name);
122
+ break;
123
+ case 'library.version':
124
+ candidates.push(span.library.version);
125
+ break;
126
+ case 'status':
127
+ case 'status.code':
128
+ candidates.push(span.status.code);
129
+ break;
130
+ }
131
+
132
+ candidates.push(getOwnProperty(span.attributes, key));
133
+ candidates.push(getPathValue(span.attributes, key));
134
+
135
+ if (!normalizedKey.startsWith('data.')) {
136
+ candidates.push(getPathValue(span.attributes.data, key));
137
+ }
138
+
139
+ return candidates.filter((value) => value !== undefined);
140
+ }
141
+
142
+ function getOwnProperty(source: unknown, key: string): unknown {
143
+ if (!source || typeof source !== 'object') return undefined;
144
+ if (!Object.hasOwn(source, key)) return undefined;
145
+ return (source as Record<string, unknown>)[key];
146
+ }
147
+
148
+ function getPathValue(source: unknown, path: string): unknown {
149
+ if (!source || typeof source !== 'object') return undefined;
150
+
151
+ let current: unknown = source;
152
+ for (const segment of path.split('.')) {
153
+ if (!segment) return undefined;
154
+ current = getOwnProperty(current, segment);
155
+ if (current === undefined) return undefined;
156
+ }
157
+
158
+ return current;
159
+ }
160
+
161
+ function valueToSearchString(value: unknown): string {
162
+ if (value instanceof Date) return value.toISOString();
163
+ if (typeof value === 'string') return value;
164
+ if (
165
+ value == null ||
166
+ typeof value === 'number' ||
167
+ typeof value === 'boolean' ||
168
+ typeof value === 'bigint'
169
+ ) {
170
+ return String(value);
171
+ }
172
+
173
+ try {
174
+ return JSON.stringify(value);
175
+ } catch {
176
+ return String(value);
177
+ }
178
+ }