@kitlangton/motel 0.1.1 → 0.1.3
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 +166 -24
- 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 +212 -20
- 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
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end reproducer for waterfall underfilling the trace-details pane.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Seed a deterministic trace into a fresh SQLite database.
|
|
6
|
+
* 2. Launch the motel TUI under tuistory in narrow mode so trace details take
|
|
7
|
+
* the full screen width.
|
|
8
|
+
* 3. Drill into the trace details view.
|
|
9
|
+
* 4. Assert the root waterfall row reaches the right-side duration column
|
|
10
|
+
* instead of stopping several cells early.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { tmpdir } from "node:os"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
|
|
18
|
+
const TUISTORY_BIN = "tuistory"
|
|
19
|
+
const SESSION = `motel-trace-width-${Date.now()}`
|
|
20
|
+
|
|
21
|
+
const hasTuistory = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
|
|
24
|
+
return (await proc.exited) === 0
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tui = async (args: readonly string[]) => {
|
|
31
|
+
const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
|
|
32
|
+
const [stdout, stderr] = await Promise.all([
|
|
33
|
+
new Response(proc.stdout).text(),
|
|
34
|
+
new Response(proc.stderr).text(),
|
|
35
|
+
])
|
|
36
|
+
return { code: await proc.exited, stdout, stderr }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
|
|
40
|
+
|
|
41
|
+
const press = async (...keys: string[]) => {
|
|
42
|
+
await tui(["press", "--session", SESSION, ...keys])
|
|
43
|
+
await Bun.sleep(120)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const dividerWidth = (snap: string) =>
|
|
47
|
+
snap.split("\n").find((line) => line.startsWith("─"))?.length ?? 0
|
|
48
|
+
|
|
49
|
+
const rootWaterfallRow = (snap: string) =>
|
|
50
|
+
snap.split("\n").find((line) => line.startsWith(" ▾ root.op") || line.startsWith(" ▸ root.op") || line.startsWith(" · root.op")) ?? null
|
|
51
|
+
|
|
52
|
+
describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
53
|
+
const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
|
|
54
|
+
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
55
|
+
const lastServicePath = join(tempDir, "last-service.txt")
|
|
56
|
+
let canRun = false
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
canRun = await hasTuistory()
|
|
60
|
+
if (!canRun) return
|
|
61
|
+
|
|
62
|
+
writeFileSync(lastServicePath, "waterfall-repro")
|
|
63
|
+
|
|
64
|
+
const seed = Bun.spawn({
|
|
65
|
+
cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
|
|
66
|
+
cwd: process.cwd(),
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
MOTEL_OTEL_DB_PATH: dbPath,
|
|
70
|
+
MOTEL_OTEL_ENABLED: "false",
|
|
71
|
+
},
|
|
72
|
+
stdout: "pipe",
|
|
73
|
+
stderr: "pipe",
|
|
74
|
+
})
|
|
75
|
+
const seedCode = await seed.exited
|
|
76
|
+
if (seedCode !== 0) {
|
|
77
|
+
const err = await new Response(seed.stderr).text()
|
|
78
|
+
throw new Error(`seed failed: ${err}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await tui(["close", "--session", SESSION])
|
|
82
|
+
const launch = await tui([
|
|
83
|
+
"launch",
|
|
84
|
+
"bun run src/index.tsx",
|
|
85
|
+
"--session", SESSION,
|
|
86
|
+
"--cols", "96",
|
|
87
|
+
"--rows", "40",
|
|
88
|
+
"--cwd", process.cwd(),
|
|
89
|
+
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
90
|
+
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
91
|
+
"--timeout", "15000",
|
|
92
|
+
])
|
|
93
|
+
if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
|
|
94
|
+
await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
|
|
95
|
+
await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
|
|
96
|
+
}, 60_000)
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
if (canRun) await tui(["close", "--session", SESSION])
|
|
100
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("fills the full-width trace details pane in narrow mode", async () => {
|
|
104
|
+
if (!canRun) return
|
|
105
|
+
|
|
106
|
+
await press("return")
|
|
107
|
+
const snap = await snapshot()
|
|
108
|
+
const divider = dividerWidth(snap)
|
|
109
|
+
const row = rootWaterfallRow(snap)
|
|
110
|
+
|
|
111
|
+
expect(divider).toBe(96)
|
|
112
|
+
expect(row).not.toBeNull()
|
|
113
|
+
expect(row!.length).toBeGreaterThanOrEqual(divider - 1)
|
|
114
|
+
}, 60_000)
|
|
115
|
+
})
|
|
@@ -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
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { useAtom } from "@effect/atom-react"
|
|
2
2
|
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
3
|
-
import { useLayoutEffect, useRef } from "react"
|
|
3
|
+
import { useEffect, useLayoutEffect, useRef } from "react"
|
|
4
4
|
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
5
5
|
import { otelServerInstructions } from "../instructions.ts"
|
|
6
6
|
import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
|
|
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,11 +28,39 @@ 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"
|
|
28
35
|
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
29
36
|
|
|
37
|
+
/**
|
|
38
|
+
* Pull a printable string out of a key event. Handles two cases:
|
|
39
|
+
*
|
|
40
|
+
* 1. A plain printable key (1 char) — returns the char.
|
|
41
|
+
* 2. A multi-char sequence that arrived as one event (common when the
|
|
42
|
+
* terminal has bracketed paste disabled but the user pasted quickly and
|
|
43
|
+
* opentui's parser returned the whole buffer as one key). Returns the
|
|
44
|
+
* sanitised sequence with control bytes stripped.
|
|
45
|
+
*
|
|
46
|
+
* Returns `null` for non-printable events (function keys, modifiers, etc.)
|
|
47
|
+
* so callers can skip them.
|
|
48
|
+
*/
|
|
49
|
+
const extractPrintable = (key: {
|
|
50
|
+
readonly name: string
|
|
51
|
+
readonly sequence?: string
|
|
52
|
+
readonly ctrl: boolean
|
|
53
|
+
readonly meta: boolean
|
|
54
|
+
}): string | null => {
|
|
55
|
+
if (key.ctrl || key.meta) return null
|
|
56
|
+
if (key.name.length === 1) return key.name
|
|
57
|
+
const seq = key.sequence ?? ""
|
|
58
|
+
// Only accept sequences that are pure printable text. Any escape or
|
|
59
|
+
// control byte means this was a function / navigation key.
|
|
60
|
+
if (seq.length > 1 && !/[\x00-\x1f\x7f]/.test(seq)) return seq
|
|
61
|
+
return null
|
|
62
|
+
}
|
|
63
|
+
|
|
30
64
|
interface KeyboardNavParams {
|
|
31
65
|
selectedTrace: TraceItem | null
|
|
32
66
|
filteredTraces: readonly TraceSummaryItem[]
|
|
@@ -65,6 +99,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
65
99
|
const [filterMode, setFilterMode] = useAtom(filterModeAtom)
|
|
66
100
|
const [filterText, setFilterText] = useAtom(filterTextAtom)
|
|
67
101
|
const [traceSort, setTraceSort] = useAtom(traceSortAtom)
|
|
102
|
+
const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
|
|
103
|
+
const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
|
|
104
|
+
const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
|
|
105
|
+
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
106
|
+
const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
|
|
107
|
+
const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
|
|
68
108
|
|
|
69
109
|
const pendingGRef = useRef(false)
|
|
70
110
|
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
@@ -73,12 +113,50 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
73
113
|
const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
|
|
74
114
|
const serviceLogNavActive = detailView === "service-logs"
|
|
75
115
|
|
|
76
|
-
|
|
116
|
+
// Bracketed paste: when the terminal has bracketed paste enabled, opentui
|
|
117
|
+
// surfaces the full pasted text as a single "paste" event on keyInput.
|
|
118
|
+
// Route it into whichever input is currently open. We also enable the
|
|
119
|
+
// mode ourselves (`\x1b[?2004h`) in case the host terminal didn't — it's
|
|
120
|
+
// a no-op on terminals that already had it on.
|
|
121
|
+
useEffect(() => {
|
|
122
|
+
const keyInput = (renderer as unknown as { keyInput?: { on: (event: string, handler: (e: unknown) => void) => void; off: (event: string, handler: (e: unknown) => void) => void } }).keyInput
|
|
123
|
+
if (!keyInput) return
|
|
124
|
+
try {
|
|
125
|
+
process.stdout.write("\x1b[?2004h")
|
|
126
|
+
} catch {
|
|
127
|
+
// Best effort — some test environments don't have a real TTY.
|
|
128
|
+
}
|
|
129
|
+
const handler = (event: unknown) => {
|
|
130
|
+
const bytes = (event as { bytes?: Uint8Array }).bytes
|
|
131
|
+
if (!bytes || bytes.length === 0) return
|
|
132
|
+
const text = Buffer.from(bytes).toString("utf8").replace(/[\x00-\x1f\x7f]+/g, (match) => match === "\n" ? " " : "")
|
|
133
|
+
if (!text) return
|
|
134
|
+
const s = stateRef.current
|
|
135
|
+
if (s.pickerMode !== "off") {
|
|
136
|
+
setPickerInput((current) => current + text)
|
|
137
|
+
setPickerIndex(0)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
if (s.filterMode) {
|
|
141
|
+
setFilterText((current) => current + text)
|
|
142
|
+
return
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
keyInput.on("paste", handler)
|
|
146
|
+
return () => {
|
|
147
|
+
keyInput.off("paste", handler)
|
|
148
|
+
try {
|
|
149
|
+
process.stdout.write("\x1b[?2004l")
|
|
150
|
+
} catch {}
|
|
151
|
+
}
|
|
152
|
+
}, [renderer, setFilterText, setPickerInput, setPickerIndex])
|
|
153
|
+
|
|
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 })
|
|
77
155
|
// Keep the keyboard handler's state mirror in sync before the next paint.
|
|
78
156
|
// OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
|
|
79
157
|
// rapid repeated keypresses can otherwise observe stale selection state.
|
|
80
158
|
useLayoutEffect(() => {
|
|
81
|
-
stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params }
|
|
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 }
|
|
82
160
|
})
|
|
83
161
|
|
|
84
162
|
const clearPendingG = () => {
|
|
@@ -230,7 +308,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
230
308
|
if (s.serviceLogState.data.length === 0) return 0
|
|
231
309
|
return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
|
|
232
310
|
})
|
|
233
|
-
} else if (s.spanNavActive
|
|
311
|
+
} else if (s.spanNavActive) {
|
|
312
|
+
if (!s.selectedTrace) return
|
|
234
313
|
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
235
314
|
setSelectedSpanIndex((current) => {
|
|
236
315
|
if (visibleCount === 0) return null
|
|
@@ -257,6 +336,83 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
257
336
|
useKeyboard((key) => {
|
|
258
337
|
const s = $()
|
|
259
338
|
|
|
339
|
+
// Attribute picker modal owns the keyboard while open.
|
|
340
|
+
if (s.pickerMode !== "off") {
|
|
341
|
+
const rows = filterFacets(s.attrFacets.data, s.pickerInput)
|
|
342
|
+
const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(s.pickerIndex, rows.length - 1))
|
|
343
|
+
const move = (delta: number) => {
|
|
344
|
+
if (rows.length === 0) return
|
|
345
|
+
setPickerIndex(Math.max(0, Math.min(clampedIndex + delta, rows.length - 1)))
|
|
346
|
+
}
|
|
347
|
+
if (key.name === "escape") {
|
|
348
|
+
setPickerMode("off")
|
|
349
|
+
setPickerInput("")
|
|
350
|
+
setPickerIndex(0)
|
|
351
|
+
return
|
|
352
|
+
}
|
|
353
|
+
// Ctrl-C: clear input, or close the picker if already empty.
|
|
354
|
+
if (key.ctrl && key.name === "c") {
|
|
355
|
+
if (s.pickerInput.length > 0) {
|
|
356
|
+
setPickerInput("")
|
|
357
|
+
setPickerIndex(0)
|
|
358
|
+
} else {
|
|
359
|
+
setPickerMode("off")
|
|
360
|
+
setPickerIndex(0)
|
|
361
|
+
}
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
|
|
365
|
+
if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
|
|
366
|
+
if (key.name === "pageup") { move(-10); return }
|
|
367
|
+
if (key.name === "pagedown") { move(10); return }
|
|
368
|
+
if (key.name === "return" || key.name === "enter") {
|
|
369
|
+
const row = rows[clampedIndex]
|
|
370
|
+
if (!row) return
|
|
371
|
+
if (s.pickerMode === "keys") {
|
|
372
|
+
// Drill from keys → values for this key.
|
|
373
|
+
setActiveAttrKey(row.value)
|
|
374
|
+
setPickerMode("values")
|
|
375
|
+
setPickerInput("")
|
|
376
|
+
setPickerIndex(0)
|
|
377
|
+
} else {
|
|
378
|
+
// Apply: activeAttrKey is already set, now pin the value.
|
|
379
|
+
setActiveAttrValue(row.value)
|
|
380
|
+
setPickerMode("off")
|
|
381
|
+
setPickerInput("")
|
|
382
|
+
setPickerIndex(0)
|
|
383
|
+
s.flashNotice(`Filter: ${s.activeAttrKey}=${row.value}`)
|
|
384
|
+
}
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
if (key.name === "backspace") {
|
|
388
|
+
if (s.pickerInput.length > 0) {
|
|
389
|
+
setPickerInput(s.pickerInput.slice(0, -1))
|
|
390
|
+
setPickerIndex(0)
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
// At empty input in values mode, backspace walks back to keys.
|
|
394
|
+
if (s.pickerMode === "values") {
|
|
395
|
+
setPickerMode("keys")
|
|
396
|
+
setActiveAttrKey(null)
|
|
397
|
+
setPickerIndex(0)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
// Prefer key.sequence over key.name so multi-char paste events that
|
|
403
|
+
// slip through as a single raw sequence still get inserted in full.
|
|
404
|
+
const printable = extractPrintable(key)
|
|
405
|
+
if (printable) {
|
|
406
|
+
// Functional setState — multiple key events in the same tick would
|
|
407
|
+
// otherwise all read a stale stateRef.current.pickerInput and
|
|
408
|
+
// clobber each other, losing all but the last char of a paste.
|
|
409
|
+
setPickerInput((current) => current + printable)
|
|
410
|
+
setPickerIndex(0)
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
|
|
260
416
|
// Filter mode: capture text input
|
|
261
417
|
if (s.filterMode) {
|
|
262
418
|
if (key.name === "escape") {
|
|
@@ -264,17 +420,28 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
264
420
|
setFilterText("")
|
|
265
421
|
return
|
|
266
422
|
}
|
|
423
|
+
// Ctrl-C: clear the input, or exit filter mode if already empty.
|
|
424
|
+
if (key.ctrl && key.name === "c") {
|
|
425
|
+
if (s.filterText.length > 0) {
|
|
426
|
+
setFilterText("")
|
|
427
|
+
} else {
|
|
428
|
+
setFilterMode(false)
|
|
429
|
+
}
|
|
430
|
+
return
|
|
431
|
+
}
|
|
267
432
|
if (key.name === "return" || key.name === "enter") {
|
|
268
433
|
setFilterMode(false)
|
|
269
434
|
return
|
|
270
435
|
}
|
|
271
436
|
if (key.name === "backspace") {
|
|
272
|
-
setFilterText(
|
|
437
|
+
setFilterText((current) => current.slice(0, -1))
|
|
273
438
|
return
|
|
274
439
|
}
|
|
275
|
-
|
|
276
|
-
if (
|
|
277
|
-
|
|
440
|
+
const printable = extractPrintable(key)
|
|
441
|
+
if (printable) {
|
|
442
|
+
// Functional setState so rapid keystrokes / pastes don't clobber
|
|
443
|
+
// each other via a stale stateRef.current.filterText closure.
|
|
444
|
+
setFilterText((current) => current + printable)
|
|
278
445
|
return
|
|
279
446
|
}
|
|
280
447
|
return
|
|
@@ -365,6 +532,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
365
532
|
setSelectedSpanIndex(null)
|
|
366
533
|
return
|
|
367
534
|
}
|
|
535
|
+
// At the trace list, `esc` clears any applied attribute filter so
|
|
536
|
+
// there's a clean way back to the unfiltered list without hunting
|
|
537
|
+
// for the picker key.
|
|
538
|
+
if (s.activeAttrKey || s.activeAttrValue) {
|
|
539
|
+
setActiveAttrKey(null)
|
|
540
|
+
setActiveAttrValue(null)
|
|
541
|
+
s.flashNotice("Cleared attribute filter")
|
|
542
|
+
return
|
|
543
|
+
}
|
|
368
544
|
return
|
|
369
545
|
}
|
|
370
546
|
if (key.name === "return" || key.name === "enter") {
|
|
@@ -416,6 +592,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
416
592
|
setFilterMode(true)
|
|
417
593
|
return
|
|
418
594
|
}
|
|
595
|
+
if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
|
|
596
|
+
// Open attribute picker at the keys step. If a filter is already
|
|
597
|
+
// applied, reopening lets the user refine or switch.
|
|
598
|
+
setPickerMode("keys")
|
|
599
|
+
setPickerInput("")
|
|
600
|
+
setPickerIndex(0)
|
|
601
|
+
setActiveAttrKey(null)
|
|
602
|
+
return
|
|
603
|
+
}
|
|
419
604
|
if (key.name === "tab") {
|
|
420
605
|
toggleServiceLogsView()
|
|
421
606
|
return
|
|
@@ -431,12 +616,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
431
616
|
if (key.name === "up" || key.name === "k") {
|
|
432
617
|
if (s.serviceLogNavActive) {
|
|
433
618
|
moveServiceLogBy(-1)
|
|
434
|
-
} else if (s.spanNavActive
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
619
|
+
} else if (s.spanNavActive) {
|
|
620
|
+
// Locked to span nav; never fall through to trace-list nav while
|
|
621
|
+
// drilled in. If the trace detail is still loading, swallow the
|
|
622
|
+
// key instead of silently leaking it to the trace list.
|
|
623
|
+
if (s.selectedTrace) {
|
|
624
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
625
|
+
setSelectedSpanIndex((current) => {
|
|
626
|
+
if (current === null || visibleCount === 0) return 0
|
|
627
|
+
return Math.max(0, current - 1)
|
|
628
|
+
})
|
|
629
|
+
}
|
|
440
630
|
} else {
|
|
441
631
|
moveTraceBy(-1)
|
|
442
632
|
}
|
|
@@ -445,12 +635,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
|
445
635
|
if (key.name === "down" || key.name === "j") {
|
|
446
636
|
if (s.serviceLogNavActive) {
|
|
447
637
|
moveServiceLogBy(1)
|
|
448
|
-
} else if (s.spanNavActive
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
638
|
+
} else if (s.spanNavActive) {
|
|
639
|
+
if (s.selectedTrace) {
|
|
640
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
641
|
+
setSelectedSpanIndex((current) => {
|
|
642
|
+
if (current === null || visibleCount === 0) return 0
|
|
643
|
+
return Math.min(current + 1, visibleCount - 1)
|
|
644
|
+
})
|
|
645
|
+
}
|
|
454
646
|
} else {
|
|
455
647
|
moveTraceBy(1)
|
|
456
648
|
}
|
|
@@ -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
|
|