@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/Waterfall.tsx
CHANGED
|
@@ -251,6 +251,7 @@ const WaterfallRow = memo(({
|
|
|
251
251
|
collapsed,
|
|
252
252
|
hasChildSpans,
|
|
253
253
|
suffixMetrics,
|
|
254
|
+
dimmed,
|
|
254
255
|
onSelect,
|
|
255
256
|
}: {
|
|
256
257
|
span: TraceSpanItem
|
|
@@ -262,6 +263,7 @@ const WaterfallRow = memo(({
|
|
|
262
263
|
collapsed: boolean
|
|
263
264
|
hasChildSpans: boolean
|
|
264
265
|
suffixMetrics: WaterfallSuffixMetrics
|
|
266
|
+
dimmed: boolean
|
|
265
267
|
onSelect: () => void
|
|
266
268
|
}) => {
|
|
267
269
|
const prefix = buildTreePrefix(spans, index)
|
|
@@ -282,12 +284,23 @@ const WaterfallRow = memo(({
|
|
|
282
284
|
const rowBg = selected ? colors.selectedBg : colors.screenBg
|
|
283
285
|
const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
|
|
284
286
|
const bg = selected ? colors.selectedBg : undefined
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
const
|
|
287
|
+
// Dimmed rows (non-matching under an active waterfall filter) collapse
|
|
288
|
+
// their palette to the muted separator color so matches stand out.
|
|
289
|
+
// Selection always wins — the selected row keeps its full brightness
|
|
290
|
+
// so you can still see where the cursor is while scanning.
|
|
291
|
+
const treeColor = selected ? colors.separator : dimmed ? colors.separator : colors.treeLine
|
|
292
|
+
const indicatorColor = selected ? colors.selectedText
|
|
293
|
+
: dimmed ? colors.separator
|
|
294
|
+
: isError ? colors.error
|
|
295
|
+
: hasChildSpans ? colors.muted
|
|
296
|
+
: colors.passing
|
|
297
|
+
const opColor = selected ? colors.selectedText
|
|
298
|
+
: dimmed ? colors.separator
|
|
299
|
+
: span.isRunning ? colors.warning
|
|
300
|
+
: colors.text
|
|
301
|
+
|
|
302
|
+
const durationFg = selected ? colors.selectedText : dimmed ? colors.separator : durationColor(span.durationMs)
|
|
303
|
+
const unitFg = dimmed && !selected ? colors.separator : colors.muted
|
|
291
304
|
|
|
292
305
|
// Split the duration so the unit (s/ms) renders dimmer than the number.
|
|
293
306
|
const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
|
|
@@ -373,6 +386,7 @@ export const WaterfallTimeline = ({
|
|
|
373
386
|
bodyLines,
|
|
374
387
|
selectedSpanIndex,
|
|
375
388
|
collapsedSpanIds,
|
|
389
|
+
matchingSpanIds,
|
|
376
390
|
onSelectSpan,
|
|
377
391
|
}: {
|
|
378
392
|
trace: TraceItem
|
|
@@ -383,6 +397,11 @@ export const WaterfallTimeline = ({
|
|
|
383
397
|
bodyLines: number
|
|
384
398
|
selectedSpanIndex: number | null
|
|
385
399
|
collapsedSpanIds: ReadonlySet<string>
|
|
400
|
+
/**
|
|
401
|
+
* When set, spans whose spanId is NOT in this set are dimmed. Null
|
|
402
|
+
* means no filter active — skip the per-row lookup entirely.
|
|
403
|
+
*/
|
|
404
|
+
matchingSpanIds?: ReadonlySet<string> | null
|
|
386
405
|
onSelectSpan: (index: number) => void
|
|
387
406
|
}) => {
|
|
388
407
|
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
@@ -392,29 +411,37 @@ export const WaterfallTimeline = ({
|
|
|
392
411
|
spanIndexById.set(trace.spans[i].spanId, i)
|
|
393
412
|
}
|
|
394
413
|
|
|
395
|
-
// Virtual windowing: only render visible rows
|
|
396
|
-
// the
|
|
414
|
+
// Virtual windowing: only render visible rows. We track scroll offset
|
|
415
|
+
// as state so the mouse wheel can scroll the window INDEPENDENTLY of
|
|
416
|
+
// the selected span (mirrors TraceList behavior). Selection still
|
|
417
|
+
// follows: if the user moves selection off-screen via j/k, we nudge
|
|
418
|
+
// the window to keep it visible — but wheel-scrolling never changes
|
|
419
|
+
// selection, only clicking a row does.
|
|
397
420
|
const viewportSize = Math.max(1, bodyLines)
|
|
398
|
-
const
|
|
421
|
+
const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
|
|
422
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
399
423
|
const lastTraceIdRef = useRef<string | null>(null)
|
|
400
424
|
|
|
401
|
-
// Reset scroll offset when the trace changes
|
|
425
|
+
// Reset scroll offset when the trace changes.
|
|
402
426
|
if (trace.traceId !== lastTraceIdRef.current) {
|
|
403
|
-
|
|
427
|
+
setScrollOffset(0)
|
|
404
428
|
lastTraceIdRef.current = trace.traceId
|
|
405
429
|
}
|
|
406
430
|
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
431
|
+
// Auto-follow selection: only if the selected span would be hidden
|
|
432
|
+
// by the current window, shift just enough to bring it back. Runs in
|
|
433
|
+
// layout effect so the visible window is accurate on the same paint
|
|
434
|
+
// that the selection changed.
|
|
435
|
+
useLayoutEffect(() => {
|
|
436
|
+
if (selectedSpanIndex === null) return
|
|
437
|
+
setScrollOffset((current) => {
|
|
438
|
+
if (selectedSpanIndex < current) return selectedSpanIndex
|
|
439
|
+
if (selectedSpanIndex >= current + viewportSize) return selectedSpanIndex - viewportSize + 1
|
|
440
|
+
return current
|
|
441
|
+
})
|
|
442
|
+
}, [selectedSpanIndex, viewportSize])
|
|
443
|
+
|
|
444
|
+
const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
|
|
418
445
|
const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
|
|
419
446
|
const blankCount = Math.max(0, viewportSize - windowSpans.length)
|
|
420
447
|
|
|
@@ -422,19 +449,17 @@ export const WaterfallTimeline = ({
|
|
|
422
449
|
// row's duration cell lines up on the same right-edge column.
|
|
423
450
|
const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
|
|
424
451
|
|
|
425
|
-
// Mouse wheel
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
452
|
+
// Mouse wheel scrolls the window without touching selection — matches
|
|
453
|
+
// the trace list, so the user can browse ahead of their cursor freely
|
|
454
|
+
// and click a row to commit. Delta is scaled 1:1 with opentui's wheel
|
|
455
|
+
// reporting (1 notch ≈ 3 rows on most terminals).
|
|
429
456
|
const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
|
|
430
457
|
const info = event.scroll
|
|
431
458
|
if (!info || filteredSpans.length === 0) return
|
|
432
459
|
const magnitude = Math.max(1, Math.round(info.delta))
|
|
433
460
|
const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
|
|
434
461
|
if (signed === 0) return
|
|
435
|
-
|
|
436
|
-
const next = Math.max(0, Math.min(start + signed, filteredSpans.length - 1))
|
|
437
|
-
if (next !== selectedSpanIndex) onSelectSpan(next)
|
|
462
|
+
setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
|
|
438
463
|
event.stopPropagation?.()
|
|
439
464
|
}
|
|
440
465
|
|
|
@@ -443,6 +468,7 @@ export const WaterfallTimeline = ({
|
|
|
443
468
|
{windowSpans.map((span, index) => {
|
|
444
469
|
const actualIndex = windowStart + index
|
|
445
470
|
const fullIndex = spanIndexById.get(span.spanId) ?? -1
|
|
471
|
+
const dimmed = matchingSpanIds != null && !matchingSpanIds.has(span.spanId)
|
|
446
472
|
return (
|
|
447
473
|
<WaterfallRow
|
|
448
474
|
key={`${trace.traceId}-${span.spanId}`}
|
|
@@ -455,6 +481,7 @@ export const WaterfallTimeline = ({
|
|
|
455
481
|
collapsed={collapsedSpanIds.has(span.spanId)}
|
|
456
482
|
hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
|
|
457
483
|
suffixMetrics={suffixMetrics}
|
|
484
|
+
dimmed={dimmed}
|
|
458
485
|
onSelect={() => onSelectSpan(actualIndex)}
|
|
459
486
|
/>
|
|
460
487
|
)
|
|
@@ -18,6 +18,8 @@ interface TraceWorkspaceProps {
|
|
|
18
18
|
readonly detailView: DetailView
|
|
19
19
|
readonly filterMode: boolean
|
|
20
20
|
readonly filterText: string
|
|
21
|
+
readonly waterfallFilterMode: boolean
|
|
22
|
+
readonly waterfallFilterText: string
|
|
21
23
|
readonly traceListProps: TraceListProps
|
|
22
24
|
readonly selectedTraceService: string | null
|
|
23
25
|
readonly serviceLogState: ServiceLogState
|
|
@@ -40,6 +42,8 @@ export const TraceWorkspace = ({
|
|
|
40
42
|
detailView,
|
|
41
43
|
filterMode,
|
|
42
44
|
filterText,
|
|
45
|
+
waterfallFilterMode,
|
|
46
|
+
waterfallFilterText,
|
|
43
47
|
traceListProps,
|
|
44
48
|
selectedTraceService,
|
|
45
49
|
serviceLogState,
|
|
@@ -132,7 +136,7 @@ export const TraceWorkspace = ({
|
|
|
132
136
|
selectedSpanIndex={selectedSpanIndex}
|
|
133
137
|
collapsedSpanIds={collapsedSpanIds}
|
|
134
138
|
focused={false}
|
|
135
|
-
onSelectSpan={selectSpan}
|
|
139
|
+
waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
|
|
136
140
|
/>
|
|
137
141
|
</box>
|
|
138
142
|
</box>
|
|
@@ -159,7 +163,7 @@ export const TraceWorkspace = ({
|
|
|
159
163
|
selectedSpanIndex={selectedSpanIndex}
|
|
160
164
|
collapsedSpanIds={collapsedSpanIds}
|
|
161
165
|
focused={true}
|
|
162
|
-
onSelectSpan={selectSpan}
|
|
166
|
+
waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
|
|
163
167
|
/>
|
|
164
168
|
</box>
|
|
165
169
|
</box>
|
|
@@ -182,7 +186,7 @@ export const TraceWorkspace = ({
|
|
|
182
186
|
selectedSpanIndex={selectedSpanIndex}
|
|
183
187
|
collapsedSpanIds={collapsedSpanIds}
|
|
184
188
|
focused={false}
|
|
185
|
-
onSelectSpan={selectSpan}
|
|
189
|
+
waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
|
|
186
190
|
/>
|
|
187
191
|
</box>
|
|
188
192
|
<SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
|
|
@@ -226,7 +230,7 @@ export const TraceWorkspace = ({
|
|
|
226
230
|
selectedSpanIndex={selectedSpanIndex}
|
|
227
231
|
collapsedSpanIds={collapsedSpanIds}
|
|
228
232
|
focused={false}
|
|
229
|
-
onSelectSpan={selectSpan}
|
|
233
|
+
waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
|
|
230
234
|
/>
|
|
231
235
|
</>
|
|
232
236
|
)
|
|
@@ -265,7 +269,7 @@ export const TraceWorkspace = ({
|
|
|
265
269
|
selectedSpanIndex={selectedSpanIndex}
|
|
266
270
|
collapsedSpanIds={collapsedSpanIds}
|
|
267
271
|
focused={true}
|
|
268
|
-
onSelectSpan={selectSpan}
|
|
272
|
+
waterfallFilterMode={waterfallFilterMode} waterfallFilterText={waterfallFilterText} onSelectSpan={selectSpan}
|
|
269
273
|
/>
|
|
270
274
|
) : (
|
|
271
275
|
<SpanDetailPane
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { useAtom } from "@effect/atom-react"
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef } from "react"
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
3
3
|
import { config } from "../../config.js"
|
|
4
4
|
import type { LogItem, TraceItem } from "../../domain.ts"
|
|
5
5
|
import {
|
|
@@ -32,6 +32,7 @@ import {
|
|
|
32
32
|
traceSortAtom,
|
|
33
33
|
traceStateAtom,
|
|
34
34
|
} from "../state.ts"
|
|
35
|
+
import { parseFilterText } from "../filterParser.ts"
|
|
35
36
|
import { getVisibleSpans } from "../Waterfall.tsx"
|
|
36
37
|
|
|
37
38
|
export const useTraceScreenData = () => {
|
|
@@ -54,6 +55,17 @@ export const useTraceScreenData = () => {
|
|
|
54
55
|
const [activeAttrValue] = useAtom(activeAttrValueAtom)
|
|
55
56
|
const [traceSort] = useAtom(traceSortAtom)
|
|
56
57
|
|
|
58
|
+
// `:ai <query>` is parsed out of the filter text and debounced so
|
|
59
|
+
// typing doesn't hammer FTS. The other modifiers (:error, operation
|
|
60
|
+
// needle) stay client-side since we already have those on trace
|
|
61
|
+
// summaries. 250ms feels responsive without firing on every keystroke.
|
|
62
|
+
const parsedFilter = useMemo(() => parseFilterText(filterText), [filterText])
|
|
63
|
+
const [debouncedAiText, setDebouncedAiText] = useState<string | null>(parsedFilter.aiText)
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const handle = setTimeout(() => setDebouncedAiText(parsedFilter.aiText), 250)
|
|
66
|
+
return () => clearTimeout(handle)
|
|
67
|
+
}, [parsedFilter.aiText])
|
|
68
|
+
|
|
57
69
|
const selectedTraceRef = useRef<string | null>(null)
|
|
58
70
|
const cacheEpochRef = useRef(0)
|
|
59
71
|
const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
|
|
@@ -99,9 +111,18 @@ export const useTraceScreenData = () => {
|
|
|
99
111
|
setSelectedTraceService(effectiveService)
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
// Branch on whether any server-side filter is active. `:ai`
|
|
115
|
+
// (debouncedAiText) and the attr picker compose; either
|
|
116
|
+
// alone also uses the filtered loader. Unfiltered falls
|
|
117
|
+
// back to the fast recent-summaries path.
|
|
118
|
+
const hasAttrFilter = Boolean(activeAttrKey && activeAttrValue)
|
|
119
|
+
const hasAiFilter = Boolean(debouncedAiText)
|
|
102
120
|
const traces = effectiveService
|
|
103
|
-
? (
|
|
104
|
-
? await loadFilteredTraceSummaries(effectiveService, {
|
|
121
|
+
? (hasAttrFilter || hasAiFilter
|
|
122
|
+
? await loadFilteredTraceSummaries(effectiveService, {
|
|
123
|
+
attributeFilters: hasAttrFilter ? { [activeAttrKey as string]: activeAttrValue as string } : undefined,
|
|
124
|
+
aiText: hasAiFilter ? debouncedAiText : null,
|
|
125
|
+
})
|
|
105
126
|
: await loadRecentTraceSummaries(effectiveService))
|
|
106
127
|
: []
|
|
107
128
|
if (cancelled) return
|
|
@@ -126,7 +147,7 @@ export const useTraceScreenData = () => {
|
|
|
126
147
|
return () => {
|
|
127
148
|
cancelled = true
|
|
128
149
|
}
|
|
129
|
-
}, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
|
|
150
|
+
}, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, debouncedAiText, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
|
|
130
151
|
|
|
131
152
|
useEffect(() => {
|
|
132
153
|
setSelectedTraceIndex((current) => {
|
|
@@ -359,13 +380,14 @@ export const useTraceScreenData = () => {
|
|
|
359
380
|
})
|
|
360
381
|
}, [serviceLogState.data.length, setSelectedServiceLogIndex])
|
|
361
382
|
|
|
383
|
+
// Client-side filters: `:error` + operation-name needle both run
|
|
384
|
+
// against already-loaded summaries (no server round-trip). The `:ai`
|
|
385
|
+
// query, by contrast, is applied server-side in the load effect
|
|
386
|
+
// above so we don't need to re-filter it here.
|
|
362
387
|
const preFilterTraces = filterText
|
|
363
388
|
? traceState.data.filter((trace) => {
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
const textNeedle = needle.replace(":error", "").trim()
|
|
367
|
-
if (errorOnly && trace.errorCount === 0) return false
|
|
368
|
-
if (textNeedle && !trace.rootOperationName.toLowerCase().includes(textNeedle)) return false
|
|
389
|
+
if (parsedFilter.errorOnly && trace.errorCount === 0) return false
|
|
390
|
+
if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
|
|
369
391
|
return true
|
|
370
392
|
})
|
|
371
393
|
: traceState.data
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { parseFilterText } from "./filterParser.ts"
|
|
3
|
+
|
|
4
|
+
describe("parseFilterText", () => {
|
|
5
|
+
it("returns empty state for empty input", () => {
|
|
6
|
+
expect(parseFilterText("")).toEqual({ aiText: null, errorOnly: false, operationNeedle: "" })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("extracts a bare operation-name needle", () => {
|
|
10
|
+
expect(parseFilterText("streamText")).toEqual({
|
|
11
|
+
aiText: null, errorOnly: false, operationNeedle: "streamtext",
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("recognizes :error modifier", () => {
|
|
16
|
+
expect(parseFilterText(":error")).toEqual({
|
|
17
|
+
aiText: null, errorOnly: true, operationNeedle: "",
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("composes :error with an operation needle", () => {
|
|
22
|
+
expect(parseFilterText("llm :error")).toEqual({
|
|
23
|
+
aiText: null, errorOnly: true, operationNeedle: "llm",
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("extracts :ai query up to end of string", () => {
|
|
28
|
+
expect(parseFilterText(":ai rate limit")).toEqual({
|
|
29
|
+
aiText: "rate limit", errorOnly: false, operationNeedle: "",
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("extracts :ai query stopping at the next modifier", () => {
|
|
34
|
+
expect(parseFilterText(":ai tool_use :error")).toEqual({
|
|
35
|
+
aiText: "tool_use", errorOnly: true, operationNeedle: "",
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("composes operation needle + :ai + :error", () => {
|
|
40
|
+
expect(parseFilterText("stream :ai rate :error")).toEqual({
|
|
41
|
+
aiText: "rate", errorOnly: true, operationNeedle: "stream",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("ignores :ai with empty query", () => {
|
|
46
|
+
expect(parseFilterText(":ai ")).toEqual({
|
|
47
|
+
aiText: null, errorOnly: false, operationNeedle: "",
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("is case-insensitive for modifiers", () => {
|
|
52
|
+
expect(parseFilterText(":AI foo :ERROR")).toEqual({
|
|
53
|
+
aiText: "foo", errorOnly: true, operationNeedle: "",
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses the `/` filter input in the trace list into its component
|
|
3
|
+
* modifiers. Supports composable tokens:
|
|
4
|
+
*
|
|
5
|
+
* - `:error` — restricts to traces with at least one failed span
|
|
6
|
+
* - `:ai <query...>` — FTS-backed search against LLM prompt/response
|
|
7
|
+
* content (AI_FTS_KEYS) across every span in the
|
|
8
|
+
* trace. The query runs up to the next `:modifier`
|
|
9
|
+
* or end of input, so `"/ :ai rate limit :error"`
|
|
10
|
+
* passes `"rate limit"` as the aiText and also
|
|
11
|
+
* sets errorOnly.
|
|
12
|
+
* - bare text — case-insensitive substring match against the
|
|
13
|
+
* trace's root operation name (client-side)
|
|
14
|
+
*
|
|
15
|
+
* Keep this a pure function: it's unit-tested separately and the React
|
|
16
|
+
* hook just calls it per render.
|
|
17
|
+
*/
|
|
18
|
+
export interface ParsedFilter {
|
|
19
|
+
readonly aiText: string | null
|
|
20
|
+
readonly errorOnly: boolean
|
|
21
|
+
readonly operationNeedle: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const parseFilterText = (raw: string): ParsedFilter => {
|
|
25
|
+
const text = raw ?? ""
|
|
26
|
+
|
|
27
|
+
// `:ai <query>` — greedy up to the next `:` modifier or end. The
|
|
28
|
+
// leading `(^|\s)` guard prevents matching a stray `:ai` glued to
|
|
29
|
+
// non-space characters (shouldn't happen in practice but cheap).
|
|
30
|
+
const aiMatch = text.match(/(?:^|\s):ai\s+([^:]*?)(?=\s:|$)/i)
|
|
31
|
+
const aiText = aiMatch?.[1]?.trim() || null
|
|
32
|
+
|
|
33
|
+
const errorOnly = /(?:^|\s):error(?=\s|$)/i.test(text)
|
|
34
|
+
|
|
35
|
+
// Whatever remains after removing the recognized modifiers becomes the
|
|
36
|
+
// operation-name needle. Lowercased here so the call site can do a
|
|
37
|
+
// plain includes() without re-lowercasing.
|
|
38
|
+
const operationNeedle = text
|
|
39
|
+
.replace(/(?:^|\s):ai\s+[^:]*?(?=\s:|$)/i, " ")
|
|
40
|
+
.replace(/(?:^|\s):error(?=\s|$)/i, " ")
|
|
41
|
+
.trim()
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
|
|
44
|
+
return { aiText, errorOnly, operationNeedle }
|
|
45
|
+
}
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -80,13 +80,25 @@ export const SeparatorColumn = ({ height, junctionChars }: { height: number; jun
|
|
|
80
80
|
)
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
export const FilterBar = ({ text, width }: { text: string; width: number }) =>
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
83
|
+
export const FilterBar = ({ text, width }: { text: string; width: number }) => {
|
|
84
|
+
// Layout: "/<text>█ op name · :error · :ai <query>"
|
|
85
|
+
// Cursor block sits immediately after the typed text (no padding —
|
|
86
|
+
// fitCell would pad the empty string to fill the available width and
|
|
87
|
+
// push the cursor to the far right, which looked like a bug). The
|
|
88
|
+
// hint only renders while the input is empty so real typing never
|
|
89
|
+
// collides with it.
|
|
90
|
+
const hint = text.length === 0 ? " op name · :error · :ai <query>" : ""
|
|
91
|
+
const textMaxWidth = Math.max(1, width - 2 - hint.length)
|
|
92
|
+
const displayText = text.length > textMaxWidth ? text.slice(text.length - textMaxWidth) : text
|
|
93
|
+
return (
|
|
94
|
+
<TextLine fg={colors.accent}>
|
|
95
|
+
<span fg={colors.muted}>{"/"}</span>
|
|
96
|
+
<span fg={colors.text}>{displayText}</span>
|
|
97
|
+
<span fg={colors.accent}>{"\u2588"}</span>
|
|
98
|
+
{hint ? <span fg={colors.muted}>{hint}</span> : null}
|
|
99
|
+
</TextLine>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
90
102
|
|
|
91
103
|
const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
|
|
92
104
|
|
|
@@ -118,7 +130,7 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
|
|
|
118
130
|
{row("t", `cycle theme (${themeLabel})`)}
|
|
119
131
|
{row("tab", "toggle service logs")}
|
|
120
132
|
{row("[ ]", "switch service")}
|
|
121
|
-
{row("/", "filter by root operation")}
|
|
133
|
+
{row("/", "filter by root operation (\u2003:error, :ai <query>)")}
|
|
122
134
|
{row("f", "filter traces by span attribute")}
|
|
123
135
|
{row("s", "cycle sort mode")}
|
|
124
136
|
{row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
|
package/src/ui/state.ts
CHANGED
|
@@ -106,6 +106,13 @@ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
|
106
106
|
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
107
107
|
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
108
108
|
|
|
109
|
+
// Waterfall-scoped filter: the `/` key while drilled into a trace
|
|
110
|
+
// (viewLevel >= 1) opens this filter instead of the trace-list one.
|
|
111
|
+
// Purely client-side — dims spans whose operation name and attribute
|
|
112
|
+
// values don't contain the needle.
|
|
113
|
+
export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
114
|
+
export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
115
|
+
|
|
109
116
|
// Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
|
|
110
117
|
export type AttrPickerMode = "off" | "keys" | "values"
|
|
111
118
|
export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
|
|
@@ -158,8 +165,31 @@ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<s
|
|
|
158
165
|
|
|
159
166
|
export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
|
|
160
167
|
export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
|
|
161
|
-
|
|
162
|
-
|
|
168
|
+
/**
|
|
169
|
+
* Server-side trace summary search. Accepts any combination of:
|
|
170
|
+
*
|
|
171
|
+
* - `attributeFilters` — exact-match span attributes (from the `f` picker)
|
|
172
|
+
* - `aiText` — FTS5-backed search across LLM prompt/response
|
|
173
|
+
* content (AI_FTS_KEYS), from the `:ai <query>`
|
|
174
|
+
* modifier in the `/` filter
|
|
175
|
+
*
|
|
176
|
+
* Both filters compose: when both are set, a trace must match both. When
|
|
177
|
+
* neither is set, callers should prefer `loadRecentTraceSummaries` so
|
|
178
|
+
* the server can skip the search path entirely.
|
|
179
|
+
*/
|
|
180
|
+
export const loadFilteredTraceSummaries = (
|
|
181
|
+
serviceName: string,
|
|
182
|
+
options: {
|
|
183
|
+
readonly attributeFilters?: Readonly<Record<string, string>>
|
|
184
|
+
readonly aiText?: string | null
|
|
185
|
+
},
|
|
186
|
+
) =>
|
|
187
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
|
|
188
|
+
serviceName,
|
|
189
|
+
attributeFilters: options.attributeFilters,
|
|
190
|
+
aiText: options.aiText ?? null,
|
|
191
|
+
limit: config.otel.traceFetchLimit,
|
|
192
|
+
})))
|
|
163
193
|
export const loadTraceAttributeKeys = (serviceName: string) =>
|
|
164
194
|
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
|
|
165
195
|
export const loadTraceAttributeValues = (serviceName: string, key: string) =>
|