@kitlangton/motel 0.1.1 → 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/package.json +2 -1
- 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 -46
- 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/traceDetailsWidth.repro.test.ts +115 -0
- 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/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
|
|