@kitlangton/motel 0.1.3 → 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 +138 -5
- 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 +91 -3
- 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
|
@@ -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) =>
|
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -27,11 +27,14 @@ 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
|
|
|
37
40
|
/**
|
|
@@ -53,6 +56,10 @@ const extractPrintable = (key: {
|
|
|
53
56
|
readonly meta: boolean
|
|
54
57
|
}): string | null => {
|
|
55
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 " "
|
|
56
63
|
if (key.name.length === 1) return key.name
|
|
57
64
|
const seq = key.sequence ?? ""
|
|
58
65
|
// Only accept sequences that are pure printable text. Any escape or
|
|
@@ -105,6 +112,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
105
112
|
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
106
113
|
const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
|
|
107
114
|
const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
|
|
115
|
+
const [waterfallFilterMode, setWaterfallFilterMode] = useAtom(waterfallFilterModeAtom)
|
|
116
|
+
const [waterfallFilterText, setWaterfallFilterText] = useAtom(waterfallFilterTextAtom)
|
|
108
117
|
|
|
109
118
|
const pendingGRef = useRef(false)
|
|
110
119
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -151,12 +160,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
151
160
|
}
|
|
152
161
|
}, [renderer, setFilterText, setPickerInput, setPickerIndex])
|
|
153
162
|
|
|
154
|
-
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, ...params })
|
|
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 })
|
|
155
164
|
// Keep the keyboard handler's state mirror in sync before the next paint.
|
|
156
165
|
// OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
|
|
157
166
|
// rapid repeated keypresses can otherwise observe stale selection state.
|
|
158
167
|
useLayoutEffect(() => {
|
|
159
|
-
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 }
|
|
160
169
|
})
|
|
161
170
|
|
|
162
171
|
const clearPendingG = () => {
|
|
@@ -446,6 +455,43 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
446
455
|
}
|
|
447
456
|
return
|
|
448
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)
|
|
491
|
+
return
|
|
492
|
+
}
|
|
493
|
+
return
|
|
494
|
+
}
|
|
449
495
|
const plainG = key.name === "g" && !key.ctrl && !key.meta && !key.option && !key.shift
|
|
450
496
|
const shiftedG = key.name === "g" && key.shift
|
|
451
497
|
const questionMark = key.name === "?" || (key.name === "/" && key.shift)
|
|
@@ -524,6 +570,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
524
570
|
setShowHelp(false)
|
|
525
571
|
return
|
|
526
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
|
+
}
|
|
527
581
|
if (s.detailView === "span-detail" || s.detailView === "service-logs") {
|
|
528
582
|
setDetailView("waterfall")
|
|
529
583
|
return
|
|
@@ -588,8 +642,42 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
588
642
|
s.flashNotice(`Theme: ${themeLabel(nextTheme)}`)
|
|
589
643
|
return
|
|
590
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
|
+
|
|
591
669
|
if (key.name === "/" && !key.shift) {
|
|
592
|
-
|
|
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
|
+
}
|
|
593
681
|
return
|
|
594
682
|
}
|
|
595
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
|
+
}
|