@kitlangton/motel 0.1.2 → 0.2.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.
- package/AGENTS.md +6 -1
- package/package.json +1 -1
- package/src/App.tsx +6 -0
- package/src/domain.ts +46 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +1 -0
- package/src/services/TelemetryStore.ts +209 -10
- package/src/services/TraceQueryService.ts +1 -1
- package/src/telemetry.test.ts +33 -0
- package/src/ui/TraceDetailsPane.tsx +33 -2
- package/src/ui/Waterfall.tsx +56 -29
- package/src/ui/app/TraceWorkspace.tsx +9 -5
- package/src/ui/app/useTraceScreenData.ts +31 -9
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/primitives.tsx +20 -8
- package/src/ui/state.ts +32 -2
- package/src/ui/useKeyboardNav.ts +191 -10
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useAtom } from "@effect/atom-react"
|
|
2
2
|
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
3
|
-
import { useLayoutEffect, useRef } from "react"
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef } from "react"
|
|
4
4
|
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
5
5
|
import { otelServerInstructions } from "../instructions.ts"
|
|
6
6
|
import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
|
|
@@ -27,13 +27,47 @@ import {
|
|
|
27
27
|
traceSortAtom,
|
|
28
28
|
type TraceSortMode,
|
|
29
29
|
traceStateAtom,
|
|
30
|
+
waterfallFilterModeAtom,
|
|
31
|
+
waterfallFilterTextAtom,
|
|
30
32
|
} from "./state.ts"
|
|
31
33
|
import { filterFacets } from "./AttrFilterModal.tsx"
|
|
32
34
|
import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
|
|
33
35
|
import { cycleThemeName, themeLabel } from "./theme.ts"
|
|
34
36
|
import { getVisibleSpans } from "./Waterfall.tsx"
|
|
37
|
+
import { computeMatchingSpanIds, findAdjacentMatch } from "./waterfallFilter.ts"
|
|
35
38
|
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
36
39
|
|
|
40
|
+
/**
|
|
41
|
+
* Pull a printable string out of a key event. Handles two cases:
|
|
42
|
+
*
|
|
43
|
+
* 1. A plain printable key (1 char) — returns the char.
|
|
44
|
+
* 2. A multi-char sequence that arrived as one event (common when the
|
|
45
|
+
* terminal has bracketed paste disabled but the user pasted quickly and
|
|
46
|
+
* opentui's parser returned the whole buffer as one key). Returns the
|
|
47
|
+
* sanitised sequence with control bytes stripped.
|
|
48
|
+
*
|
|
49
|
+
* Returns `null` for non-printable events (function keys, modifiers, etc.)
|
|
50
|
+
* so callers can skip them.
|
|
51
|
+
*/
|
|
52
|
+
const extractPrintable = (key: {
|
|
53
|
+
readonly name: string
|
|
54
|
+
readonly sequence?: string
|
|
55
|
+
readonly ctrl: boolean
|
|
56
|
+
readonly meta: boolean
|
|
57
|
+
}): string | null => {
|
|
58
|
+
if (key.ctrl || key.meta) return null
|
|
59
|
+
// Space arrives as `key.name === "space"` with a 1-char sequence. We
|
|
60
|
+
// handle it explicitly because the generic "length > 1" branch below
|
|
61
|
+
// only catches multi-char paste sequences, not a lone " ".
|
|
62
|
+
if (key.name === "space") return " "
|
|
63
|
+
if (key.name.length === 1) return key.name
|
|
64
|
+
const seq = key.sequence ?? ""
|
|
65
|
+
// Only accept sequences that are pure printable text. Any escape or
|
|
66
|
+
// control byte means this was a function / navigation key.
|
|
67
|
+
if (seq.length > 1 && !/[\x00-\x1f\x7f]/.test(seq)) return seq
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
37
71
|
interface KeyboardNavParams {
|
|
38
72
|
selectedTrace: TraceItem | null
|
|
39
73
|
filteredTraces: readonly TraceSummaryItem[]
|
|
@@ -78,6 +112,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
78
112
|
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
79
113
|
const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
|
|
80
114
|
const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
|
|
115
|
+
const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
|
|
116
|
+
const [waterfallFilterText, setWaterfallFilterText] = useAtom(waterfallFilterTextAtom)
|
|
81
117
|
|
|
82
118
|
const pendingGRef = useRef(false)
|
|
83
119
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -86,12 +122,50 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
86
122
|
const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
|
|
87
123
|
const serviceLogNavActive = detailView === "service-logs"
|
|
88
124
|
|
|
89
|
-
|
|
125
|
+
// Bracketed paste: when the terminal has bracketed paste enabled, opentui
|
|
126
|
+
// surfaces the full pasted text as a single "paste" event on keyInput.
|
|
127
|
+
// Route it into whichever input is currently open. We also enable the
|
|
128
|
+
// mode ourselves (`\x1b[?2004h`) in case the host terminal didn't — it's
|
|
129
|
+
// a no-op on terminals that already had it on.
|
|
130
|
+
useEffect(() => {
|
|
131
|
+
const keyInput = (renderer as unknown as { keyInput?: { on: (event: string, handler: (e: unknown) => void) => void; off: (event: string, handler: (e: unknown) => void) => void } }).keyInput
|
|
132
|
+
if (!keyInput) return
|
|
133
|
+
try {
|
|
134
|
+
process.stdout.write("\x1b[?2004h")
|
|
135
|
+
} catch {
|
|
136
|
+
// Best effort — some test environments don't have a real TTY.
|
|
137
|
+
}
|
|
138
|
+
const handler = (event: unknown) => {
|
|
139
|
+
const bytes = (event as { bytes?: Uint8Array }).bytes
|
|
140
|
+
if (!bytes || bytes.length === 0) return
|
|
141
|
+
const text = Buffer.from(bytes).toString("utf8").replace(/[\x00-\x1f\x7f]+/g, (match) => match === "\n" ? " " : "")
|
|
142
|
+
if (!text) return
|
|
143
|
+
const s = stateRef.current
|
|
144
|
+
if (s.pickerMode !== "off") {
|
|
145
|
+
setPickerInput((current) => current + text)
|
|
146
|
+
setPickerIndex(0)
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
if (s.filterMode) {
|
|
150
|
+
setFilterText((current) => current + text)
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
keyInput.on("paste", handler)
|
|
155
|
+
return () => {
|
|
156
|
+
keyInput.off("paste", handler)
|
|
157
|
+
try {
|
|
158
|
+
process.stdout.write("\x1b[?2004l")
|
|
159
|
+
} catch {}
|
|
160
|
+
}
|
|
161
|
+
}, [renderer, setFilterText, setPickerInput, setPickerIndex])
|
|
162
|
+
|
|
163
|
+
const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, waterfallFilterMode, waterfallFilterText, ...params })
|
|
90
164
|
// Keep the keyboard handler's state mirror in sync before the next paint.
|
|
91
165
|
// OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
|
|
92
166
|
// rapid repeated keypresses can otherwise observe stale selection state.
|
|
93
167
|
useLayoutEffect(() => {
|
|
94
|
-
stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params }
|
|
168
|
+
stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, waterfallFilterMode, waterfallFilterText, ...params }
|
|
95
169
|
})
|
|
96
170
|
|
|
97
171
|
const clearPendingG = () => {
|
|
@@ -285,6 +359,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
285
359
|
setPickerIndex(0)
|
|
286
360
|
return
|
|
287
361
|
}
|
|
362
|
+
// Ctrl-C: clear input, or close the picker if already empty.
|
|
363
|
+
if (key.ctrl && key.name === "c") {
|
|
364
|
+
if (s.pickerInput.length > 0) {
|
|
365
|
+
setPickerInput("")
|
|
366
|
+
setPickerIndex(0)
|
|
367
|
+
} else {
|
|
368
|
+
setPickerMode("off")
|
|
369
|
+
setPickerIndex(0)
|
|
370
|
+
}
|
|
371
|
+
return
|
|
372
|
+
}
|
|
288
373
|
if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
|
|
289
374
|
if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
|
|
290
375
|
if (key.name === "pageup") { move(-10); return }
|
|
@@ -323,8 +408,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
323
408
|
}
|
|
324
409
|
return
|
|
325
410
|
}
|
|
326
|
-
|
|
327
|
-
|
|
411
|
+
// Prefer key.sequence over key.name so multi-char paste events that
|
|
412
|
+
// slip through as a single raw sequence still get inserted in full.
|
|
413
|
+
const printable = extractPrintable(key)
|
|
414
|
+
if (printable) {
|
|
415
|
+
// Functional setState — multiple key events in the same tick would
|
|
416
|
+
// otherwise all read a stale stateRef.current.pickerInput and
|
|
417
|
+
// clobber each other, losing all but the last char of a paste.
|
|
418
|
+
setPickerInput((current) => current + printable)
|
|
328
419
|
setPickerIndex(0)
|
|
329
420
|
return
|
|
330
421
|
}
|
|
@@ -338,17 +429,65 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
338
429
|
setFilterText("")
|
|
339
430
|
return
|
|
340
431
|
}
|
|
432
|
+
// Ctrl-C: clear the input, or exit filter mode if already empty.
|
|
433
|
+
if (key.ctrl && key.name === "c") {
|
|
434
|
+
if (s.filterText.length > 0) {
|
|
435
|
+
setFilterText("")
|
|
436
|
+
} else {
|
|
437
|
+
setFilterMode(false)
|
|
438
|
+
}
|
|
439
|
+
return
|
|
440
|
+
}
|
|
341
441
|
if (key.name === "return" || key.name === "enter") {
|
|
342
442
|
setFilterMode(false)
|
|
343
443
|
return
|
|
344
444
|
}
|
|
345
445
|
if (key.name === "backspace") {
|
|
346
|
-
setFilterText(
|
|
446
|
+
setFilterText((current) => current.slice(0, -1))
|
|
347
447
|
return
|
|
348
448
|
}
|
|
349
|
-
|
|
350
|
-
if (
|
|
351
|
-
|
|
449
|
+
const printable = extractPrintable(key)
|
|
450
|
+
if (printable) {
|
|
451
|
+
// Functional setState so rapid keystrokes / pastes don't clobber
|
|
452
|
+
// each other via a stale stateRef.current.filterText closure.
|
|
453
|
+
setFilterText((current) => current + printable)
|
|
454
|
+
return
|
|
455
|
+
}
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Waterfall filter mode: text-capture scoped to the current
|
|
460
|
+
// trace's spans.
|
|
461
|
+
// - enter → commit: close input but keep text so dimming persists
|
|
462
|
+
// while the user navigates. `/` can be pressed again
|
|
463
|
+
// to edit.
|
|
464
|
+
// - esc → cancel: clear text + exit input entirely.
|
|
465
|
+
// - ctrl-c → clear input if non-empty, otherwise exit.
|
|
466
|
+
if (s.waterfallFilterMode) {
|
|
467
|
+
if (key.name === "escape") {
|
|
468
|
+
setWaterfallFilterMode(false)
|
|
469
|
+
setWaterfallFilterText("")
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
if (key.ctrl && key.name === "c") {
|
|
473
|
+
if (s.waterfallFilterText.length > 0) {
|
|
474
|
+
setWaterfallFilterText("")
|
|
475
|
+
} else {
|
|
476
|
+
setWaterfallFilterMode(false)
|
|
477
|
+
}
|
|
478
|
+
return
|
|
479
|
+
}
|
|
480
|
+
if (key.name === "return" || key.name === "enter") {
|
|
481
|
+
setWaterfallFilterMode(false)
|
|
482
|
+
return
|
|
483
|
+
}
|
|
484
|
+
if (key.name === "backspace") {
|
|
485
|
+
setWaterfallFilterText((current) => current.slice(0, -1))
|
|
486
|
+
return
|
|
487
|
+
}
|
|
488
|
+
const printable = extractPrintable(key)
|
|
489
|
+
if (printable) {
|
|
490
|
+
setWaterfallFilterText((current) => current + printable)
|
|
352
491
|
return
|
|
353
492
|
}
|
|
354
493
|
return
|
|
@@ -431,6 +570,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
431
570
|
setShowHelp(false)
|
|
432
571
|
return
|
|
433
572
|
}
|
|
573
|
+
// Committed waterfall filter outranks drill-back: hitting esc
|
|
574
|
+
// should clear the dim before jumping you out of the span
|
|
575
|
+
// detail pane. That keeps a single `esc` predictable whether
|
|
576
|
+
// the filter was applied by typing or left over from before.
|
|
577
|
+
if (s.waterfallFilterText.length > 0) {
|
|
578
|
+
setWaterfallFilterText("")
|
|
579
|
+
return
|
|
580
|
+
}
|
|
434
581
|
if (s.detailView === "span-detail" || s.detailView === "service-logs") {
|
|
435
582
|
setDetailView("waterfall")
|
|
436
583
|
return
|
|
@@ -495,8 +642,42 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
495
642
|
s.flashNotice(`Theme: ${themeLabel(nextTheme)}`)
|
|
496
643
|
return
|
|
497
644
|
}
|
|
645
|
+
// `n` / `N`: jump between matches of the committed waterfall filter.
|
|
646
|
+
// Only active when drilled into a trace AND the filter has text
|
|
647
|
+
// (committed or live — either way, there's a dim/highlight we can
|
|
648
|
+
// step through). Wraps at the ends like vim's /n. Plain `n` forward,
|
|
649
|
+
// shift-n (`N`) backward.
|
|
650
|
+
if ((key.name === "n" || key.name === "N") && !key.ctrl && !key.meta) {
|
|
651
|
+
const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
|
|
652
|
+
if (inWaterfall && s.waterfallFilterText.length > 0 && s.selectedTrace) {
|
|
653
|
+
const visibleSpans = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds)
|
|
654
|
+
const matchingIds = computeMatchingSpanIds(visibleSpans, s.waterfallFilterText)
|
|
655
|
+
if (matchingIds && matchingIds.size > 0) {
|
|
656
|
+
const direction = key.name === "N" ? -1 : 1
|
|
657
|
+
const next = findAdjacentMatch(visibleSpans, matchingIds, s.selectedSpanIndex, direction)
|
|
658
|
+
if (next !== null) setSelectedSpanIndex(next)
|
|
659
|
+
else s.flashNotice("No matches")
|
|
660
|
+
} else {
|
|
661
|
+
s.flashNotice("No matches")
|
|
662
|
+
}
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
// Fall through when not in a trace detail view — reserves `n`
|
|
666
|
+
// for other future bindings without shadowing them globally.
|
|
667
|
+
}
|
|
668
|
+
|
|
498
669
|
if (key.name === "/" && !key.shift) {
|
|
499
|
-
|
|
670
|
+
// When drilled into a trace (viewLevel >= 1 — waterfall or
|
|
671
|
+
// span detail is the dominant pane), `/` opens a filter scoped
|
|
672
|
+
// to the current trace's spans instead of the trace list.
|
|
673
|
+
// Drill level here is inferred from selectedSpanIndex/detailView
|
|
674
|
+
// the same way useAppLayout does it.
|
|
675
|
+
const inWaterfall = s.detailView === "span-detail" || s.selectedSpanIndex !== null
|
|
676
|
+
if (inWaterfall) {
|
|
677
|
+
setWaterfallFilterMode(true)
|
|
678
|
+
} else {
|
|
679
|
+
setFilterMode(true)
|
|
680
|
+
}
|
|
500
681
|
return
|
|
501
682
|
}
|
|
502
683
|
if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import type { TraceSpanItem } from "../domain.ts"
|
|
3
|
+
import { computeMatchingSpanIds, findAdjacentMatch, spanMatchesFilter } from "./waterfallFilter.ts"
|
|
4
|
+
|
|
5
|
+
const span = (spanId: string, operationName: string, tags: Record<string, string> = {}): TraceSpanItem => ({
|
|
6
|
+
spanId,
|
|
7
|
+
parentSpanId: null,
|
|
8
|
+
serviceName: "test",
|
|
9
|
+
scopeName: null,
|
|
10
|
+
kind: null,
|
|
11
|
+
operationName,
|
|
12
|
+
startTime: new Date(0),
|
|
13
|
+
isRunning: false,
|
|
14
|
+
durationMs: 1,
|
|
15
|
+
status: "ok",
|
|
16
|
+
depth: 0,
|
|
17
|
+
tags,
|
|
18
|
+
warnings: [],
|
|
19
|
+
events: [],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("spanMatchesFilter", () => {
|
|
23
|
+
it("matches operation name case-insensitively", () => {
|
|
24
|
+
expect(spanMatchesFilter(span("a", "ai.StreamText"), "stream")).toBe(true)
|
|
25
|
+
expect(spanMatchesFilter(span("a", "ai.StreamText"), "nope")).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("matches tag values but not keys", () => {
|
|
29
|
+
expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "claude")).toBe(true)
|
|
30
|
+
// Key-only match should not count, otherwise searching "ai" dims nothing.
|
|
31
|
+
expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "model.id")).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("returns true when the needle is empty", () => {
|
|
35
|
+
expect(spanMatchesFilter(span("a", "op"), "")).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("computeMatchingSpanIds", () => {
|
|
40
|
+
it("returns null for empty/whitespace filter", () => {
|
|
41
|
+
expect(computeMatchingSpanIds([span("a", "op")], "")).toBeNull()
|
|
42
|
+
expect(computeMatchingSpanIds([span("a", "op")], " ")).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("returns only matching span ids", () => {
|
|
46
|
+
const spans = [span("a", "ai.streamText"), span("b", "Agent.get"), span("c", "ai.toolCall")]
|
|
47
|
+
const ids = computeMatchingSpanIds(spans, "ai")
|
|
48
|
+
expect(ids).not.toBeNull()
|
|
49
|
+
expect(Array.from(ids!)).toEqual(["a", "c"])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("findAdjacentMatch", () => {
|
|
54
|
+
const spans = [span("a", "one"), span("b", "two"), span("c", "three"), span("d", "four")]
|
|
55
|
+
const matches = new Set(["b", "d"])
|
|
56
|
+
|
|
57
|
+
it("finds next from current selection", () => {
|
|
58
|
+
expect(findAdjacentMatch(spans, matches, 0, 1)).toBe(1) // a -> b
|
|
59
|
+
expect(findAdjacentMatch(spans, matches, 1, 1)).toBe(3) // b -> d
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("wraps forward past the end", () => {
|
|
63
|
+
expect(findAdjacentMatch(spans, matches, 3, 1)).toBe(1) // d -> b (wrap)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("finds previous from current selection", () => {
|
|
67
|
+
expect(findAdjacentMatch(spans, matches, 3, -1)).toBe(1) // d -> b
|
|
68
|
+
expect(findAdjacentMatch(spans, matches, 1, -1)).toBe(3) // b -> d (wrap)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("starts from beginning/end when nothing is selected", () => {
|
|
72
|
+
expect(findAdjacentMatch(spans, matches, null, 1)).toBe(1) // forward → first match
|
|
73
|
+
expect(findAdjacentMatch(spans, matches, null, -1)).toBe(3) // backward → last match
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("returns null when there are no matches", () => {
|
|
77
|
+
expect(findAdjacentMatch(spans, new Set(), 0, 1)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("handles a single match by returning it regardless of direction", () => {
|
|
81
|
+
expect(findAdjacentMatch(spans, new Set(["c"]), 0, 1)).toBe(2)
|
|
82
|
+
expect(findAdjacentMatch(spans, new Set(["c"]), 0, -1)).toBe(2)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TraceSpanItem } from "../domain.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Case-insensitive substring match against a span's operation name and
|
|
5
|
+
* any of its tag values. Keys aren't checked — they're dotted
|
|
6
|
+
* identifiers the user never types, so matching them just creates false
|
|
7
|
+
* positives on bare tokens like "ai" or "response".
|
|
8
|
+
*/
|
|
9
|
+
export const spanMatchesFilter = (span: TraceSpanItem, needle: string): boolean => {
|
|
10
|
+
if (!needle) return true
|
|
11
|
+
if (span.operationName.toLowerCase().includes(needle)) return true
|
|
12
|
+
for (const value of Object.values(span.tags)) {
|
|
13
|
+
if (typeof value === "string" && value.toLowerCase().includes(needle)) return true
|
|
14
|
+
}
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute the set of span IDs that match the given filter text. Returns
|
|
20
|
+
* null when the filter is empty so callers can skip dimming entirely
|
|
21
|
+
* (hot path during waterfall scrolling).
|
|
22
|
+
*/
|
|
23
|
+
export const computeMatchingSpanIds = (
|
|
24
|
+
spans: readonly TraceSpanItem[],
|
|
25
|
+
filterText: string,
|
|
26
|
+
): ReadonlySet<string> | null => {
|
|
27
|
+
const needle = filterText.trim().toLowerCase()
|
|
28
|
+
if (!needle) return null
|
|
29
|
+
const matches = new Set<string>()
|
|
30
|
+
for (const span of spans) {
|
|
31
|
+
if (spanMatchesFilter(span, needle)) matches.add(span.spanId)
|
|
32
|
+
}
|
|
33
|
+
return matches
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Find the next (direction=1) or previous (direction=-1) matching span
|
|
38
|
+
* index in `filteredSpans` relative to `currentIndex`. Wraps around the
|
|
39
|
+
* ends. Returns `null` when the list has no matches at all. When
|
|
40
|
+
* `currentIndex` is null (nothing selected), starts from either end
|
|
41
|
+
* based on direction.
|
|
42
|
+
*/
|
|
43
|
+
export const findAdjacentMatch = (
|
|
44
|
+
filteredSpans: readonly TraceSpanItem[],
|
|
45
|
+
matchingSpanIds: ReadonlySet<string>,
|
|
46
|
+
currentIndex: number | null,
|
|
47
|
+
direction: 1 | -1,
|
|
48
|
+
): number | null => {
|
|
49
|
+
if (filteredSpans.length === 0 || matchingSpanIds.size === 0) return null
|
|
50
|
+
const start = currentIndex ?? (direction === 1 ? -1 : filteredSpans.length)
|
|
51
|
+
const n = filteredSpans.length
|
|
52
|
+
// Walk the ring exactly once so we wrap but never loop forever.
|
|
53
|
+
for (let step = 1; step <= n; step++) {
|
|
54
|
+
const idx = ((start + direction * step) % n + n) % n
|
|
55
|
+
const span = filteredSpans[idx]
|
|
56
|
+
if (span && matchingSpanIds.has(span.spanId)) return idx
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|