@kitlangton/motel 0.1.0 → 0.1.2
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 +22 -8
- package/README.md +70 -163
- package/package.json +5 -2
- package/src/App.tsx +38 -28
- package/src/config.ts +1 -1
- package/src/httpApi.ts +5 -2
- package/src/localServer.ts +1 -0
- package/src/motel.ts +12 -0
- package/src/services/TelemetryStore.ts +99 -23
- package/src/services/TraceQueryService.ts +4 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/SpanDetailPane.tsx +1 -2
- package/src/ui/TraceDetailsPane.tsx +14 -22
- package/src/ui/TraceList.tsx +166 -40
- package/src/ui/Waterfall.tsx +104 -49
- package/src/ui/app/TraceListPane.tsx +19 -14
- package/src/ui/app/TraceWorkspace.tsx +60 -31
- package/src/ui/app/useAppLayout.ts +22 -3
- package/src/ui/app/useTraceScreenData.ts +13 -2
- package/src/ui/format.ts +14 -5
- package/src/ui/primitives.tsx +3 -1
- package/src/ui/state.ts +32 -0
- package/src/ui/theme.ts +24 -19
- package/src/ui/traceSortNav.repro.test.ts +3 -2
- package/src/ui/useAttrFilterPicker.ts +47 -0
- package/src/ui/useKeyboardNav.ts +114 -15
- package/src/ui/waterfallNav.test.ts +22 -7
- package/web/dist/assets/{index-BEKIiisE.js → index-DKinj-OE.js} +1 -1
- package/web/dist/index.html +1 -1
package/src/ui/state.ts
CHANGED
|
@@ -106,6 +106,32 @@ 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
|
+
// Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
|
|
110
|
+
export type AttrPickerMode = "off" | "keys" | "values"
|
|
111
|
+
export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
|
|
112
|
+
export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
113
|
+
export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
114
|
+
|
|
115
|
+
export interface AttrFacetState {
|
|
116
|
+
readonly status: LoadStatus
|
|
117
|
+
readonly key: string | null // null when loading keys; set when loading values
|
|
118
|
+
readonly data: readonly { readonly value: string; readonly count: number }[]
|
|
119
|
+
readonly error: string | null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export const initialAttrFacetState: AttrFacetState = {
|
|
123
|
+
status: "ready",
|
|
124
|
+
key: null,
|
|
125
|
+
data: [],
|
|
126
|
+
error: null,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
|
|
130
|
+
|
|
131
|
+
// Applied filter (drives trace list query)
|
|
132
|
+
export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
133
|
+
export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
134
|
+
|
|
109
135
|
const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
110
136
|
const readLastTheme = (): ThemeName => {
|
|
111
137
|
try {
|
|
@@ -132,6 +158,12 @@ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<s
|
|
|
132
158
|
|
|
133
159
|
export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
|
|
134
160
|
export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
|
|
161
|
+
export const loadFilteredTraceSummaries = (serviceName: string, attributeFilters: Readonly<Record<string, string>>) =>
|
|
162
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({ serviceName, attributeFilters, limit: config.otel.traceFetchLimit })))
|
|
163
|
+
export const loadTraceAttributeKeys = (serviceName: string) =>
|
|
164
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
|
|
165
|
+
export const loadTraceAttributeValues = (serviceName: string, key: string) =>
|
|
166
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
|
|
135
167
|
export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
|
|
136
168
|
export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
|
|
137
169
|
export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
|
package/src/ui/theme.ts
CHANGED
|
@@ -32,32 +32,37 @@ export interface ThemeDefinition {
|
|
|
32
32
|
readonly waterfall: ThemeWaterfallColors
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
// motel-default palette derived in OKLCH. All "surface" tokens share hue
|
|
36
|
+
// 282 (twilight purple) at varying lightness so depth is communicated by
|
|
37
|
+
// lightness alone (footer < screen < selected < bar track). The amber
|
|
38
|
+
// accent (hue 73) sits almost complementary to the surfaces, giving the
|
|
39
|
+
// motel-sign neon maximum contrast without color clash.
|
|
35
40
|
const motelDefaultTheme: ThemeDefinition = {
|
|
36
41
|
name: "motel-default",
|
|
37
42
|
label: "Motel Default",
|
|
38
43
|
colors: {
|
|
39
|
-
screenBg: "#
|
|
40
|
-
text: "#
|
|
41
|
-
muted: "#
|
|
42
|
-
separator: "#
|
|
43
|
-
accent: "#
|
|
44
|
-
error: "#
|
|
45
|
-
selectedBg: "#
|
|
46
|
-
warning: "#
|
|
44
|
+
screenBg: "#111120", // oklch(0.185 0.030 282)
|
|
45
|
+
text: "#eee5d6", // oklch(0.925 0.022 82) — warm cream
|
|
46
|
+
muted: "#9a9181", // oklch(0.660 0.025 82)
|
|
47
|
+
separator: "#686155", // oklch(0.495 0.020 81)
|
|
48
|
+
accent: "#f5a41a", // oklch(0.780 0.161 73) — motel neon
|
|
49
|
+
error: "#f97312", // oklch(0.705 0.187 48)
|
|
50
|
+
selectedBg: "#2b2c48", // oklch(0.305 0.050 282) — same hue as screen
|
|
51
|
+
warning: "#facc16", // oklch(0.861 0.173 92)
|
|
47
52
|
selectedText: "#f8fafc",
|
|
48
|
-
count: "#d7c5a1",
|
|
49
|
-
passing: "#
|
|
50
|
-
defaultService: "#
|
|
51
|
-
footerBg: "#
|
|
52
|
-
treeLine: "#
|
|
53
|
-
previewKey: "#
|
|
53
|
+
count: "#d7c5a1", // oklch(0.830 0.052 85)
|
|
54
|
+
passing: "#7ed5a4", // oklch(0.805 0.110 158)
|
|
55
|
+
defaultService: "#93c5fe", // oklch(0.810 0.096 252)
|
|
56
|
+
footerBg: "#04040e", // oklch(0.115 0.025 282) — deeper than screen
|
|
57
|
+
treeLine: "#48433b", // oklch(0.385 0.015 80)
|
|
58
|
+
previewKey: "#645d51", // oklch(0.480 0.020 80)
|
|
54
59
|
},
|
|
55
60
|
waterfall: {
|
|
56
|
-
bar: "#
|
|
57
|
-
barError: "#
|
|
58
|
-
barBg: "#
|
|
59
|
-
barLane: "#
|
|
60
|
-
barSelected: "#
|
|
61
|
+
bar: "#f5a41a", // = accent
|
|
62
|
+
barError: "#f97312", // = error
|
|
63
|
+
barBg: "#1f1f34", // oklch(0.250 0.040 282) — purple track (was warm)
|
|
64
|
+
barLane: "#3d3e5b", // oklch(0.375 0.050 282)
|
|
65
|
+
barSelected: "#f3c048", // oklch(0.832 0.145 85) — warmer amber
|
|
61
66
|
barSelectedError: "#ff8c42",
|
|
62
67
|
},
|
|
63
68
|
}
|
|
@@ -60,8 +60,9 @@ const listRows = (snap: string): { readonly rows: readonly string[]; readonly se
|
|
|
60
60
|
const leftHalf = raw.split("\u2502")[0] ?? raw
|
|
61
61
|
const rightHalf = raw.includes("\u2502") ? raw.split("\u2502").slice(1).join("\u2502") : ""
|
|
62
62
|
|
|
63
|
-
// Trace rows: left pane, `·` then
|
|
64
|
-
|
|
63
|
+
// Trace rows: left pane, `·` then the operation name as the first
|
|
64
|
+
// token. (Earlier versions appended `#<hash>`; that's been removed.)
|
|
65
|
+
const rowMatch = leftHalf.match(/^\s+\u00b7\s+(op[A-Z])\b/)
|
|
65
66
|
if (rowMatch) rows.push(rowMatch[1]!)
|
|
66
67
|
|
|
67
68
|
// Selected trace: right pane, line immediately after `TRACE DETAILS`
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { useAtom } from "@effect/atom-react"
|
|
2
|
+
import { useEffect } from "react"
|
|
3
|
+
import {
|
|
4
|
+
attrFacetStateAtom,
|
|
5
|
+
attrPickerModeAtom,
|
|
6
|
+
initialAttrFacetState,
|
|
7
|
+
loadTraceAttributeKeys,
|
|
8
|
+
loadTraceAttributeValues,
|
|
9
|
+
selectedTraceServiceAtom,
|
|
10
|
+
} from "./state.ts"
|
|
11
|
+
|
|
12
|
+
// When the picker is open, load the current facet page (keys, or values for
|
|
13
|
+
// a specific key) and keep it in sync with the selected service. We key the
|
|
14
|
+
// effect off picker mode + service + target key so refetches happen on drill
|
|
15
|
+
// in/out and when the user switches services mid-pick.
|
|
16
|
+
export const useAttrFilterPicker = (selectedKey: string | null) => {
|
|
17
|
+
const [pickerMode] = useAtom(attrPickerModeAtom)
|
|
18
|
+
const [service] = useAtom(selectedTraceServiceAtom)
|
|
19
|
+
const [, setFacetState] = useAtom(attrFacetStateAtom)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (pickerMode === "off" || !service) {
|
|
23
|
+
setFacetState(initialAttrFacetState)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
let cancelled = false
|
|
27
|
+
setFacetState({ status: "loading", key: pickerMode === "values" ? selectedKey : null, data: [], error: null })
|
|
28
|
+
const load = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const rows = pickerMode === "keys"
|
|
31
|
+
? await loadTraceAttributeKeys(service)
|
|
32
|
+
: selectedKey
|
|
33
|
+
? await loadTraceAttributeValues(service, selectedKey)
|
|
34
|
+
: []
|
|
35
|
+
if (cancelled) return
|
|
36
|
+
setFacetState({ status: "ready", key: pickerMode === "values" ? selectedKey : null, data: rows, error: null })
|
|
37
|
+
} catch (err) {
|
|
38
|
+
if (cancelled) return
|
|
39
|
+
setFacetState({ status: "error", key: pickerMode === "values" ? selectedKey : null, data: [], error: err instanceof Error ? err.message : String(err) })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
void load()
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true
|
|
45
|
+
}
|
|
46
|
+
}, [pickerMode, service, selectedKey, setFacetState])
|
|
47
|
+
}
|
package/src/ui/useKeyboardNav.ts
CHANGED
|
@@ -5,6 +5,12 @@ import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
|
5
5
|
import { otelServerInstructions } from "../instructions.ts"
|
|
6
6
|
import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
|
|
7
7
|
import {
|
|
8
|
+
activeAttrKeyAtom,
|
|
9
|
+
activeAttrValueAtom,
|
|
10
|
+
attrFacetStateAtom,
|
|
11
|
+
attrPickerIndexAtom,
|
|
12
|
+
attrPickerInputAtom,
|
|
13
|
+
attrPickerModeAtom,
|
|
8
14
|
autoRefreshAtom,
|
|
9
15
|
collapsedSpanIdsAtom,
|
|
10
16
|
detailViewAtom,
|
|
@@ -22,6 +28,7 @@ import {
|
|
|
22
28
|
type TraceSortMode,
|
|
23
29
|
traceStateAtom,
|
|
24
30
|
} from "./state.ts"
|
|
31
|
+
import { filterFacets } from "./AttrFilterModal.tsx"
|
|
25
32
|
import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
|
|
26
33
|
import { cycleThemeName, themeLabel } from "./theme.ts"
|
|
27
34
|
import { getVisibleSpans } from "./Waterfall.tsx"
|
|
@@ -65,6 +72,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
65
72
|
const [filterMode, setFilterMode] = useAtom(filterModeAtom)
|
|
66
73
|
const [filterText, setFilterText] = useAtom(filterTextAtom)
|
|
67
74
|
const [traceSort, setTraceSort] = useAtom(traceSortAtom)
|
|
75
|
+
const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
|
|
76
|
+
const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
|
|
77
|
+
const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
|
|
78
|
+
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
79
|
+
const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
|
|
80
|
+
const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
|
|
68
81
|
|
|
69
82
|
const pendingGRef = useRef(false)
|
|
70
83
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -73,12 +86,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
73
86
|
const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
|
|
74
87
|
const serviceLogNavActive = detailView === "service-logs"
|
|
75
88
|
|
|
76
|
-
const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params })
|
|
89
|
+
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 })
|
|
77
90
|
// Keep the keyboard handler's state mirror in sync before the next paint.
|
|
78
91
|
// OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
|
|
79
92
|
// rapid repeated keypresses can otherwise observe stale selection state.
|
|
80
93
|
useLayoutEffect(() => {
|
|
81
|
-
stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params }
|
|
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 }
|
|
82
95
|
})
|
|
83
96
|
|
|
84
97
|
const clearPendingG = () => {
|
|
@@ -230,7 +243,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
230
243
|
if (s.serviceLogState.data.length === 0) return 0
|
|
231
244
|
return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
|
|
232
245
|
})
|
|
233
|
-
} else if (s.spanNavActive
|
|
246
|
+
} else if (s.spanNavActive) {
|
|
247
|
+
if (!s.selectedTrace) return
|
|
234
248
|
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
235
249
|
setSelectedSpanIndex((current) => {
|
|
236
250
|
if (visibleCount === 0) return null
|
|
@@ -257,6 +271,66 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
257
271
|
useKeyboard((key) => {
|
|
258
272
|
const s = $()
|
|
259
273
|
|
|
274
|
+
// Attribute picker modal owns the keyboard while open.
|
|
275
|
+
if (s.pickerMode !== "off") {
|
|
276
|
+
const rows = filterFacets(s.attrFacets.data, s.pickerInput)
|
|
277
|
+
const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(s.pickerIndex, rows.length - 1))
|
|
278
|
+
const move = (delta: number) => {
|
|
279
|
+
if (rows.length === 0) return
|
|
280
|
+
setPickerIndex(Math.max(0, Math.min(clampedIndex + delta, rows.length - 1)))
|
|
281
|
+
}
|
|
282
|
+
if (key.name === "escape") {
|
|
283
|
+
setPickerMode("off")
|
|
284
|
+
setPickerInput("")
|
|
285
|
+
setPickerIndex(0)
|
|
286
|
+
return
|
|
287
|
+
}
|
|
288
|
+
if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
|
|
289
|
+
if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
|
|
290
|
+
if (key.name === "pageup") { move(-10); return }
|
|
291
|
+
if (key.name === "pagedown") { move(10); return }
|
|
292
|
+
if (key.name === "return" || key.name === "enter") {
|
|
293
|
+
const row = rows[clampedIndex]
|
|
294
|
+
if (!row) return
|
|
295
|
+
if (s.pickerMode === "keys") {
|
|
296
|
+
// Drill from keys → values for this key.
|
|
297
|
+
setActiveAttrKey(row.value)
|
|
298
|
+
setPickerMode("values")
|
|
299
|
+
setPickerInput("")
|
|
300
|
+
setPickerIndex(0)
|
|
301
|
+
} else {
|
|
302
|
+
// Apply: activeAttrKey is already set, now pin the value.
|
|
303
|
+
setActiveAttrValue(row.value)
|
|
304
|
+
setPickerMode("off")
|
|
305
|
+
setPickerInput("")
|
|
306
|
+
setPickerIndex(0)
|
|
307
|
+
s.flashNotice(`Filter: ${s.activeAttrKey}=${row.value}`)
|
|
308
|
+
}
|
|
309
|
+
return
|
|
310
|
+
}
|
|
311
|
+
if (key.name === "backspace") {
|
|
312
|
+
if (s.pickerInput.length > 0) {
|
|
313
|
+
setPickerInput(s.pickerInput.slice(0, -1))
|
|
314
|
+
setPickerIndex(0)
|
|
315
|
+
return
|
|
316
|
+
}
|
|
317
|
+
// At empty input in values mode, backspace walks back to keys.
|
|
318
|
+
if (s.pickerMode === "values") {
|
|
319
|
+
setPickerMode("keys")
|
|
320
|
+
setActiveAttrKey(null)
|
|
321
|
+
setPickerIndex(0)
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
return
|
|
325
|
+
}
|
|
326
|
+
if (key.name.length === 1 && !key.ctrl && !key.meta) {
|
|
327
|
+
setPickerInput(s.pickerInput + key.name)
|
|
328
|
+
setPickerIndex(0)
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
260
334
|
// Filter mode: capture text input
|
|
261
335
|
if (s.filterMode) {
|
|
262
336
|
if (key.name === "escape") {
|
|
@@ -365,6 +439,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
365
439
|
setSelectedSpanIndex(null)
|
|
366
440
|
return
|
|
367
441
|
}
|
|
442
|
+
// At the trace list, `esc` clears any applied attribute filter so
|
|
443
|
+
// there's a clean way back to the unfiltered list without hunting
|
|
444
|
+
// for the picker key.
|
|
445
|
+
if (s.activeAttrKey || s.activeAttrValue) {
|
|
446
|
+
setActiveAttrKey(null)
|
|
447
|
+
setActiveAttrValue(null)
|
|
448
|
+
s.flashNotice("Cleared attribute filter")
|
|
449
|
+
return
|
|
450
|
+
}
|
|
368
451
|
return
|
|
369
452
|
}
|
|
370
453
|
if (key.name === "return" || key.name === "enter") {
|
|
@@ -416,6 +499,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
416
499
|
setFilterMode(true)
|
|
417
500
|
return
|
|
418
501
|
}
|
|
502
|
+
if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
|
|
503
|
+
// Open attribute picker at the keys step. If a filter is already
|
|
504
|
+
// applied, reopening lets the user refine or switch.
|
|
505
|
+
setPickerMode("keys")
|
|
506
|
+
setPickerInput("")
|
|
507
|
+
setPickerIndex(0)
|
|
508
|
+
setActiveAttrKey(null)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
419
511
|
if (key.name === "tab") {
|
|
420
512
|
toggleServiceLogsView()
|
|
421
513
|
return
|
|
@@ -431,12 +523,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
431
523
|
if (key.name === "up" || key.name === "k") {
|
|
432
524
|
if (s.serviceLogNavActive) {
|
|
433
525
|
moveServiceLogBy(-1)
|
|
434
|
-
} else if (s.spanNavActive
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
526
|
+
} else if (s.spanNavActive) {
|
|
527
|
+
// Locked to span nav; never fall through to trace-list nav while
|
|
528
|
+
// drilled in. If the trace detail is still loading, swallow the
|
|
529
|
+
// key instead of silently leaking it to the trace list.
|
|
530
|
+
if (s.selectedTrace) {
|
|
531
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
532
|
+
setSelectedSpanIndex((current) => {
|
|
533
|
+
if (current === null || visibleCount === 0) return 0
|
|
534
|
+
return Math.max(0, current - 1)
|
|
535
|
+
})
|
|
536
|
+
}
|
|
440
537
|
} else {
|
|
441
538
|
moveTraceBy(-1)
|
|
442
539
|
}
|
|
@@ -445,12 +542,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
445
542
|
if (key.name === "down" || key.name === "j") {
|
|
446
543
|
if (s.serviceLogNavActive) {
|
|
447
544
|
moveServiceLogBy(1)
|
|
448
|
-
} else if (s.spanNavActive
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
545
|
+
} else if (s.spanNavActive) {
|
|
546
|
+
if (s.selectedTrace) {
|
|
547
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
548
|
+
setSelectedSpanIndex((current) => {
|
|
549
|
+
if (current === null || visibleCount === 0) return 0
|
|
550
|
+
return Math.min(current + 1, visibleCount - 1)
|
|
551
|
+
})
|
|
552
|
+
}
|
|
454
553
|
} else {
|
|
455
554
|
moveTraceBy(1)
|
|
456
555
|
}
|
|
@@ -3,7 +3,8 @@ import type { TraceSpanItem } from "../domain.ts"
|
|
|
3
3
|
import {
|
|
4
4
|
findFirstChildIndex,
|
|
5
5
|
findParentIndex,
|
|
6
|
-
|
|
6
|
+
getWaterfallLayout,
|
|
7
|
+
getWaterfallSuffixMetrics,
|
|
7
8
|
getVisibleSpans,
|
|
8
9
|
} from "./Waterfall.tsx"
|
|
9
10
|
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
@@ -130,13 +131,27 @@ describe("getVisibleSpans", () => {
|
|
|
130
131
|
})
|
|
131
132
|
})
|
|
132
133
|
|
|
133
|
-
describe("
|
|
134
|
-
it("
|
|
134
|
+
describe("getWaterfallSuffixMetrics", () => {
|
|
135
|
+
it("uses the widest visible duration as the shared suffix width", () => {
|
|
136
|
+
const spans = [
|
|
137
|
+
{ spanId: "a", durationMs: 1 },
|
|
138
|
+
{ spanId: "b", durationMs: 57_000 },
|
|
139
|
+
{ spanId: "c", durationMs: 120 },
|
|
140
|
+
]
|
|
141
|
+
const metrics = getWaterfallSuffixMetrics(spans)
|
|
142
|
+
// `120ms` = 5 is the widest
|
|
143
|
+
expect(metrics.maxDurationWidth).toBe(5)
|
|
144
|
+
expect(metrics.suffixWidth).toBe(5)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it("layout reserves the suffix once and leaves the rest for the bar", () => {
|
|
135
148
|
const contentWidth = 72
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
149
|
+
const metrics = getWaterfallSuffixMetrics(
|
|
150
|
+
[{ spanId: "a", durationMs: 57_000 }, { spanId: "b", durationMs: 1 }],
|
|
151
|
+
)
|
|
152
|
+
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
|
|
153
|
+
// label + 1 (gap before bar) + bar + 1 (gap before suffix) + suffix = contentWidth
|
|
154
|
+
expect(labelMaxWidth + 1 + barWidth + 1 + metrics.suffixWidth).toBe(contentWidth)
|
|
140
155
|
})
|
|
141
156
|
})
|
|
142
157
|
|