@kitlangton/motel 0.1.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 +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,532 @@
|
|
|
1
|
+
import { useAtom } from "@effect/atom-react"
|
|
2
|
+
import { useKeyboard, useRenderer } from "@opentui/react"
|
|
3
|
+
import { useLayoutEffect, useRef } from "react"
|
|
4
|
+
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
5
|
+
import { otelServerInstructions } from "../instructions.ts"
|
|
6
|
+
import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
|
|
7
|
+
import {
|
|
8
|
+
autoRefreshAtom,
|
|
9
|
+
collapsedSpanIdsAtom,
|
|
10
|
+
detailViewAtom,
|
|
11
|
+
filterModeAtom,
|
|
12
|
+
filterTextAtom,
|
|
13
|
+
refreshNonceAtom,
|
|
14
|
+
selectedThemeAtom,
|
|
15
|
+
selectedServiceLogIndexAtom,
|
|
16
|
+
selectedSpanIndexAtom,
|
|
17
|
+
selectedTraceIndexAtom,
|
|
18
|
+
selectedTraceServiceAtom,
|
|
19
|
+
serviceLogStateAtom,
|
|
20
|
+
showHelpAtom,
|
|
21
|
+
traceSortAtom,
|
|
22
|
+
type TraceSortMode,
|
|
23
|
+
traceStateAtom,
|
|
24
|
+
} from "./state.ts"
|
|
25
|
+
import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
|
|
26
|
+
import { cycleThemeName, themeLabel } from "./theme.ts"
|
|
27
|
+
import { getVisibleSpans } from "./Waterfall.tsx"
|
|
28
|
+
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
29
|
+
|
|
30
|
+
interface KeyboardNavParams {
|
|
31
|
+
selectedTrace: TraceItem | null
|
|
32
|
+
filteredTraces: readonly TraceSummaryItem[]
|
|
33
|
+
isWideLayout: boolean
|
|
34
|
+
wideBodyLines: number
|
|
35
|
+
narrowBodyLines: number
|
|
36
|
+
tracePageSize: number
|
|
37
|
+
spanPageSize: number
|
|
38
|
+
flashNotice: (message: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const useKeyboardNav = (params: KeyboardNavParams) => {
|
|
42
|
+
const {
|
|
43
|
+
selectedTrace,
|
|
44
|
+
isWideLayout,
|
|
45
|
+
wideBodyLines,
|
|
46
|
+
narrowBodyLines,
|
|
47
|
+
tracePageSize,
|
|
48
|
+
spanPageSize,
|
|
49
|
+
flashNotice,
|
|
50
|
+
} = params
|
|
51
|
+
const renderer = useRenderer()
|
|
52
|
+
|
|
53
|
+
const [traceState] = useAtom(traceStateAtom)
|
|
54
|
+
const [serviceLogState] = useAtom(serviceLogStateAtom)
|
|
55
|
+
const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
|
|
56
|
+
const [selectedServiceLogIndex, setSelectedServiceLogIndex] = useAtom(selectedServiceLogIndexAtom)
|
|
57
|
+
const [selectedTheme, setSelectedTheme] = useAtom(selectedThemeAtom)
|
|
58
|
+
const [selectedTraceIndex, setSelectedTraceIndex] = useAtom(selectedTraceIndexAtom)
|
|
59
|
+
const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
|
|
60
|
+
const [detailView, setDetailView] = useAtom(detailViewAtom)
|
|
61
|
+
const [showHelp, setShowHelp] = useAtom(showHelpAtom)
|
|
62
|
+
const [, setRefreshNonce] = useAtom(refreshNonceAtom)
|
|
63
|
+
const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
|
|
64
|
+
const [autoRefresh, setAutoRefresh] = useAtom(autoRefreshAtom)
|
|
65
|
+
const [filterMode, setFilterMode] = useAtom(filterModeAtom)
|
|
66
|
+
const [filterText, setFilterText] = useAtom(filterTextAtom)
|
|
67
|
+
const [traceSort, setTraceSort] = useAtom(traceSortAtom)
|
|
68
|
+
|
|
69
|
+
const pendingGRef = useRef(false)
|
|
70
|
+
const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
71
|
+
const quittingRef = useRef(false)
|
|
72
|
+
|
|
73
|
+
const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
|
|
74
|
+
const serviceLogNavActive = detailView === "service-logs"
|
|
75
|
+
|
|
76
|
+
const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params })
|
|
77
|
+
// Keep the keyboard handler's state mirror in sync before the next paint.
|
|
78
|
+
// OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
|
|
79
|
+
// rapid repeated keypresses can otherwise observe stale selection state.
|
|
80
|
+
useLayoutEffect(() => {
|
|
81
|
+
stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params }
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const clearPendingG = () => {
|
|
85
|
+
pendingGRef.current = false
|
|
86
|
+
if (pendingGTimeoutRef.current !== null) {
|
|
87
|
+
clearTimeout(pendingGTimeoutRef.current)
|
|
88
|
+
pendingGTimeoutRef.current = null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const armPendingG = () => {
|
|
93
|
+
clearPendingG()
|
|
94
|
+
pendingGRef.current = true
|
|
95
|
+
pendingGTimeoutRef.current = globalThis.setTimeout(() => {
|
|
96
|
+
pendingGRef.current = false
|
|
97
|
+
pendingGTimeoutRef.current = null
|
|
98
|
+
}, G_PREFIX_TIMEOUT_MS)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const $ = () => stateRef.current
|
|
102
|
+
|
|
103
|
+
const selectFilteredTraceAt = (filteredIdx: number) => {
|
|
104
|
+
const s = $()
|
|
105
|
+
const trace = s.filteredTraces[filteredIdx]
|
|
106
|
+
if (!trace) return
|
|
107
|
+
const fullIndex = s.traceState.data.findIndex((t) => t.traceId === trace.traceId)
|
|
108
|
+
if (fullIndex >= 0) setSelectedTraceIndex(fullIndex)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const jumpToStart = () => {
|
|
112
|
+
const s = $()
|
|
113
|
+
if (s.spanNavActive && s.selectedTrace) {
|
|
114
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
115
|
+
setSelectedSpanIndex(visibleCount === 0 ? null : 0)
|
|
116
|
+
} else {
|
|
117
|
+
selectFilteredTraceAt(0)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const jumpToEnd = () => {
|
|
122
|
+
const s = $()
|
|
123
|
+
if (s.spanNavActive && s.selectedTrace) {
|
|
124
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
125
|
+
setSelectedSpanIndex(visibleCount === 0 ? null : visibleCount - 1)
|
|
126
|
+
} else {
|
|
127
|
+
selectFilteredTraceAt(s.filteredTraces.length - 1)
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const moveTraceBy = (direction: -1 | 1) => {
|
|
132
|
+
const s = $()
|
|
133
|
+
const filtered = s.filteredTraces
|
|
134
|
+
if (filtered.length === 0) return
|
|
135
|
+
setSelectedTraceIndex((current) => {
|
|
136
|
+
const currentTraceId = s.traceState.data[current]?.traceId
|
|
137
|
+
const currentFilteredIdx = currentTraceId
|
|
138
|
+
? filtered.findIndex((t) => t.traceId === currentTraceId)
|
|
139
|
+
: -1
|
|
140
|
+
if (currentFilteredIdx < 0) {
|
|
141
|
+
const fallbackTrace = filtered[0]
|
|
142
|
+
if (!fallbackTrace) return current
|
|
143
|
+
const fallbackIndex = s.traceState.data.findIndex((t) => t.traceId === fallbackTrace.traceId)
|
|
144
|
+
return fallbackIndex >= 0 ? fallbackIndex : current
|
|
145
|
+
}
|
|
146
|
+
const nextFilteredIdx = Math.max(0, Math.min(currentFilteredIdx + direction, filtered.length - 1))
|
|
147
|
+
const nextTrace = filtered[nextFilteredIdx]
|
|
148
|
+
if (!nextTrace) return current
|
|
149
|
+
const fullIndex = s.traceState.data.findIndex((t) => t.traceId === nextTrace.traceId)
|
|
150
|
+
return fullIndex >= 0 ? fullIndex : current
|
|
151
|
+
})
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const moveServiceLogBy = (direction: -1 | 1) => {
|
|
155
|
+
const s = $()
|
|
156
|
+
setSelectedServiceLogIndex((current) => {
|
|
157
|
+
if (s.serviceLogState.data.length === 0) return 0
|
|
158
|
+
return Math.max(0, Math.min(current + direction, s.serviceLogState.data.length - 1))
|
|
159
|
+
})
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const cycleService = (direction: -1 | 1) => {
|
|
163
|
+
const s = $()
|
|
164
|
+
if (s.traceState.services.length === 0) return
|
|
165
|
+
const currentIndex = s.selectedTraceService ? s.traceState.services.indexOf(s.selectedTraceService) : -1
|
|
166
|
+
const nextIndex = currentIndex >= 0 ? (currentIndex + direction + s.traceState.services.length) % s.traceState.services.length : 0
|
|
167
|
+
setSelectedTraceService(s.traceState.services[nextIndex] ?? s.selectedTraceService)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const refresh = (message?: string) => {
|
|
171
|
+
const s = $()
|
|
172
|
+
setRefreshNonce((current) => current + 1)
|
|
173
|
+
if (message) s.flashNotice(message)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const copySelectedIds = () => {
|
|
177
|
+
const s = $()
|
|
178
|
+
if (s.serviceLogNavActive) {
|
|
179
|
+
const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
|
|
180
|
+
if (!selectedLog?.traceId) {
|
|
181
|
+
s.flashNotice("No trace id to copy")
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const lines = [
|
|
185
|
+
`traceId=${selectedLog.traceId}`,
|
|
186
|
+
selectedLog.spanId ? `spanId=${selectedLog.spanId}` : null,
|
|
187
|
+
].filter((line): line is string => line !== null).join("\n")
|
|
188
|
+
void copyToClipboard(lines)
|
|
189
|
+
.then(() => {
|
|
190
|
+
s.flashNotice(selectedLog.spanId ? "Copied trace and span ids" : "Copied trace id")
|
|
191
|
+
})
|
|
192
|
+
.catch((error) => {
|
|
193
|
+
s.flashNotice(error instanceof Error ? error.message : String(error))
|
|
194
|
+
})
|
|
195
|
+
return
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (!s.selectedTrace) {
|
|
199
|
+
s.flashNotice("No trace selected")
|
|
200
|
+
return
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const visibleSpans = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds)
|
|
204
|
+
const selectedSpan = s.selectedSpanIndex !== null ? visibleSpans[s.selectedSpanIndex] ?? null : null
|
|
205
|
+
const lines = [
|
|
206
|
+
`traceId=${s.selectedTrace.traceId}`,
|
|
207
|
+
selectedSpan ? `spanId=${selectedSpan.spanId}` : null,
|
|
208
|
+
].filter((line): line is string => line !== null).join("\n")
|
|
209
|
+
|
|
210
|
+
void copyToClipboard(lines)
|
|
211
|
+
.then(() => {
|
|
212
|
+
s.flashNotice(selectedSpan ? "Copied trace and span ids" : "Copied trace id")
|
|
213
|
+
})
|
|
214
|
+
.catch((error) => {
|
|
215
|
+
s.flashNotice(error instanceof Error ? error.message : String(error))
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const toggleServiceLogsView = () => {
|
|
220
|
+
const s = $()
|
|
221
|
+
if (!s.selectedTraceService && !s.selectedTrace) return
|
|
222
|
+
setDetailView((current) => current === "service-logs" ? (s.selectedSpanIndex !== null ? "span-detail" : "waterfall") : "service-logs")
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const pageBy = (direction: -1 | 1) => {
|
|
226
|
+
const s = $()
|
|
227
|
+
if (s.serviceLogNavActive) {
|
|
228
|
+
const serviceLogPageSize = Math.max(1, Math.floor((s.isWideLayout ? s.wideBodyLines : s.narrowBodyLines) * 0.5))
|
|
229
|
+
setSelectedServiceLogIndex((current) => {
|
|
230
|
+
if (s.serviceLogState.data.length === 0) return 0
|
|
231
|
+
return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
|
|
232
|
+
})
|
|
233
|
+
} else if (s.spanNavActive && s.selectedTrace) {
|
|
234
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
235
|
+
setSelectedSpanIndex((current) => {
|
|
236
|
+
if (visibleCount === 0) return null
|
|
237
|
+
const start = current ?? 0
|
|
238
|
+
return Math.max(0, Math.min(start + direction * s.spanPageSize, visibleCount - 1))
|
|
239
|
+
})
|
|
240
|
+
} else {
|
|
241
|
+
const filtered = s.filteredTraces
|
|
242
|
+
if (filtered.length === 0) return
|
|
243
|
+
setSelectedTraceIndex((current) => {
|
|
244
|
+
const currentTraceId = s.traceState.data[current]?.traceId
|
|
245
|
+
const currentFilteredIdx = currentTraceId
|
|
246
|
+
? filtered.findIndex((t) => t.traceId === currentTraceId)
|
|
247
|
+
: 0
|
|
248
|
+
const nextIdx = Math.max(0, Math.min(currentFilteredIdx + direction * s.tracePageSize, filtered.length - 1))
|
|
249
|
+
const nextTrace = filtered[nextIdx]
|
|
250
|
+
if (!nextTrace) return current
|
|
251
|
+
const fullIndex = s.traceState.data.findIndex((t) => t.traceId === nextTrace.traceId)
|
|
252
|
+
return fullIndex >= 0 ? fullIndex : current
|
|
253
|
+
})
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
useKeyboard((key) => {
|
|
258
|
+
const s = $()
|
|
259
|
+
|
|
260
|
+
// Filter mode: capture text input
|
|
261
|
+
if (s.filterMode) {
|
|
262
|
+
if (key.name === "escape") {
|
|
263
|
+
setFilterMode(false)
|
|
264
|
+
setFilterText("")
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
if (key.name === "return" || key.name === "enter") {
|
|
268
|
+
setFilterMode(false)
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
if (key.name === "backspace") {
|
|
272
|
+
setFilterText(s.filterText.slice(0, -1))
|
|
273
|
+
return
|
|
274
|
+
}
|
|
275
|
+
// Single printable character
|
|
276
|
+
if (key.name.length === 1 && !key.ctrl && !key.meta) {
|
|
277
|
+
setFilterText(s.filterText + key.name)
|
|
278
|
+
return
|
|
279
|
+
}
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
const plainG = key.name === "g" && !key.ctrl && !key.meta && !key.option && !key.shift
|
|
283
|
+
const shiftedG = key.name === "g" && key.shift
|
|
284
|
+
const questionMark = key.name === "?" || (key.name === "/" && key.shift)
|
|
285
|
+
|
|
286
|
+
if (questionMark) {
|
|
287
|
+
clearPendingG()
|
|
288
|
+
setShowHelp((current) => !current)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (s.showHelp) {
|
|
293
|
+
if (key.name === "return" || key.name === "enter" || key.name === "escape") {
|
|
294
|
+
setShowHelp(false)
|
|
295
|
+
}
|
|
296
|
+
return
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (plainG && !key.repeated) {
|
|
300
|
+
if (pendingGRef.current) {
|
|
301
|
+
clearPendingG()
|
|
302
|
+
jumpToStart()
|
|
303
|
+
} else {
|
|
304
|
+
armPendingG()
|
|
305
|
+
}
|
|
306
|
+
return
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (shiftedG) {
|
|
310
|
+
clearPendingG()
|
|
311
|
+
jumpToEnd()
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
clearPendingG()
|
|
316
|
+
|
|
317
|
+
if (key.name === "q" || (key.ctrl && key.name === "c")) {
|
|
318
|
+
if (quittingRef.current) return
|
|
319
|
+
quittingRef.current = true
|
|
320
|
+
renderer.destroy()
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
if (key.name === "home") {
|
|
324
|
+
if (s.serviceLogNavActive) {
|
|
325
|
+
setSelectedServiceLogIndex(0)
|
|
326
|
+
} else {
|
|
327
|
+
jumpToStart()
|
|
328
|
+
}
|
|
329
|
+
return
|
|
330
|
+
}
|
|
331
|
+
if (key.name === "end") {
|
|
332
|
+
if (s.serviceLogNavActive) {
|
|
333
|
+
setSelectedServiceLogIndex(s.serviceLogState.data.length === 0 ? 0 : s.serviceLogState.data.length - 1)
|
|
334
|
+
} else {
|
|
335
|
+
jumpToEnd()
|
|
336
|
+
}
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
if (key.name === "pagedown" || (key.ctrl && key.name === "d")) {
|
|
340
|
+
pageBy(1)
|
|
341
|
+
return
|
|
342
|
+
}
|
|
343
|
+
if (key.name === "pageup" || (key.ctrl && key.name === "u")) {
|
|
344
|
+
pageBy(-1)
|
|
345
|
+
return
|
|
346
|
+
}
|
|
347
|
+
if (key.ctrl && key.name === "p") {
|
|
348
|
+
moveTraceBy(-1)
|
|
349
|
+
return
|
|
350
|
+
}
|
|
351
|
+
if (key.ctrl && key.name === "n") {
|
|
352
|
+
moveTraceBy(1)
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
if (key.name === "escape") {
|
|
356
|
+
if (s.showHelp) {
|
|
357
|
+
setShowHelp(false)
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
if (s.detailView === "span-detail" || s.detailView === "service-logs") {
|
|
361
|
+
setDetailView("waterfall")
|
|
362
|
+
return
|
|
363
|
+
}
|
|
364
|
+
if (s.spanNavActive) {
|
|
365
|
+
setSelectedSpanIndex(null)
|
|
366
|
+
return
|
|
367
|
+
}
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
if (key.name === "return" || key.name === "enter") {
|
|
371
|
+
if (s.detailView === "service-logs") {
|
|
372
|
+
const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
|
|
373
|
+
if (selectedLog?.traceId) {
|
|
374
|
+
const traceIndex = s.traceState.data.findIndex((trace) => trace.traceId === selectedLog.traceId)
|
|
375
|
+
if (traceIndex >= 0) {
|
|
376
|
+
setSelectedTraceIndex(traceIndex)
|
|
377
|
+
setDetailView("waterfall")
|
|
378
|
+
s.flashNotice(`Jumped to trace ${selectedLog.traceId.slice(-8)}`)
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
return
|
|
382
|
+
}
|
|
383
|
+
if (s.spanNavActive && s.detailView === "waterfall") {
|
|
384
|
+
setDetailView("span-detail")
|
|
385
|
+
return
|
|
386
|
+
}
|
|
387
|
+
if (!s.spanNavActive && s.selectedTrace && s.selectedTrace.spans.length > 0) {
|
|
388
|
+
setSelectedSpanIndex(0)
|
|
389
|
+
return
|
|
390
|
+
}
|
|
391
|
+
return
|
|
392
|
+
}
|
|
393
|
+
if (key.name === "r") {
|
|
394
|
+
refresh("Refreshing traces...")
|
|
395
|
+
return
|
|
396
|
+
}
|
|
397
|
+
if (key.name === "a") {
|
|
398
|
+
setAutoRefresh(!s.autoRefresh)
|
|
399
|
+
s.flashNotice(s.autoRefresh ? "Auto-refresh paused" : "Auto-refresh resumed")
|
|
400
|
+
return
|
|
401
|
+
}
|
|
402
|
+
if (key.name === "s") {
|
|
403
|
+
const modes: readonly TraceSortMode[] = ["recent", "slowest", "errors"]
|
|
404
|
+
const nextMode = modes[(modes.indexOf(s.traceSort) + 1) % modes.length] ?? "recent"
|
|
405
|
+
setTraceSort(nextMode)
|
|
406
|
+
s.flashNotice(`Sort: ${nextMode}`)
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
if (key.name === "t") {
|
|
410
|
+
const nextTheme = cycleThemeName(s.selectedTheme)
|
|
411
|
+
setSelectedTheme(nextTheme)
|
|
412
|
+
s.flashNotice(`Theme: ${themeLabel(nextTheme)}`)
|
|
413
|
+
return
|
|
414
|
+
}
|
|
415
|
+
if (key.name === "/" && !key.shift) {
|
|
416
|
+
setFilterMode(true)
|
|
417
|
+
return
|
|
418
|
+
}
|
|
419
|
+
if (key.name === "tab") {
|
|
420
|
+
toggleServiceLogsView()
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
if (key.name === "[") {
|
|
424
|
+
cycleService(-1)
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
if (key.name === "]") {
|
|
428
|
+
cycleService(1)
|
|
429
|
+
return
|
|
430
|
+
}
|
|
431
|
+
if (key.name === "up" || key.name === "k") {
|
|
432
|
+
if (s.serviceLogNavActive) {
|
|
433
|
+
moveServiceLogBy(-1)
|
|
434
|
+
} else if (s.spanNavActive && s.selectedTrace) {
|
|
435
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
436
|
+
setSelectedSpanIndex((current) => {
|
|
437
|
+
if (current === null || visibleCount === 0) return 0
|
|
438
|
+
return Math.max(0, current - 1)
|
|
439
|
+
})
|
|
440
|
+
} else {
|
|
441
|
+
moveTraceBy(-1)
|
|
442
|
+
}
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
if (key.name === "down" || key.name === "j") {
|
|
446
|
+
if (s.serviceLogNavActive) {
|
|
447
|
+
moveServiceLogBy(1)
|
|
448
|
+
} else if (s.spanNavActive && s.selectedTrace) {
|
|
449
|
+
const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
|
|
450
|
+
setSelectedSpanIndex((current) => {
|
|
451
|
+
if (current === null || visibleCount === 0) return 0
|
|
452
|
+
return Math.min(current + 1, visibleCount - 1)
|
|
453
|
+
})
|
|
454
|
+
} else {
|
|
455
|
+
moveTraceBy(1)
|
|
456
|
+
}
|
|
457
|
+
return
|
|
458
|
+
}
|
|
459
|
+
if (key.name === "left" || key.name === "h") {
|
|
460
|
+
if (s.spanNavActive && s.selectedTrace) {
|
|
461
|
+
const trace = s.selectedTrace
|
|
462
|
+
setCollapsedSpanIds((currentCollapsed) => {
|
|
463
|
+
const result = resolveCollapseStep({
|
|
464
|
+
spans: trace.spans,
|
|
465
|
+
collapsed: currentCollapsed,
|
|
466
|
+
selectedIndex: s.selectedSpanIndex,
|
|
467
|
+
direction: "left",
|
|
468
|
+
})
|
|
469
|
+
if (result.selectedIndex !== s.selectedSpanIndex) {
|
|
470
|
+
setSelectedSpanIndex(result.selectedIndex)
|
|
471
|
+
}
|
|
472
|
+
return result.collapsed
|
|
473
|
+
})
|
|
474
|
+
}
|
|
475
|
+
return
|
|
476
|
+
}
|
|
477
|
+
if (key.name === "right" || key.name === "l") {
|
|
478
|
+
if (s.spanNavActive && s.selectedTrace) {
|
|
479
|
+
const trace = s.selectedTrace
|
|
480
|
+
setCollapsedSpanIds((currentCollapsed) => {
|
|
481
|
+
const result = resolveCollapseStep({
|
|
482
|
+
spans: trace.spans,
|
|
483
|
+
collapsed: currentCollapsed,
|
|
484
|
+
selectedIndex: s.selectedSpanIndex,
|
|
485
|
+
direction: "right",
|
|
486
|
+
})
|
|
487
|
+
if (result.selectedIndex !== s.selectedSpanIndex) {
|
|
488
|
+
setSelectedSpanIndex(result.selectedIndex)
|
|
489
|
+
}
|
|
490
|
+
return result.collapsed
|
|
491
|
+
})
|
|
492
|
+
} else if (!s.spanNavActive && !s.serviceLogNavActive) {
|
|
493
|
+
toggleServiceLogsView()
|
|
494
|
+
}
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
if (key.name === "o" && !key.shift) {
|
|
498
|
+
if (s.serviceLogNavActive) {
|
|
499
|
+
const selectedLog = s.serviceLogState.data[s.selectedServiceLogIndex]
|
|
500
|
+
if (selectedLog?.traceId) {
|
|
501
|
+
void Bun.spawn({ cmd: ["open", traceUiUrl(selectedLog.traceId)], stdout: "ignore", stderr: "ignore" })
|
|
502
|
+
s.flashNotice(`Opened trace ${selectedLog.traceId.slice(-8)}`)
|
|
503
|
+
}
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
if (!s.selectedTrace) return
|
|
507
|
+
void Bun.spawn({ cmd: ["open", traceUiUrl(s.selectedTrace.traceId)], stdout: "ignore", stderr: "ignore" })
|
|
508
|
+
s.flashNotice(`Opened trace ${s.selectedTrace.traceId.slice(-8)}`)
|
|
509
|
+
return
|
|
510
|
+
}
|
|
511
|
+
if (key.name === "o" && key.shift) {
|
|
512
|
+
void Bun.spawn({ cmd: ["open", webUiUrl()], stdout: "ignore", stderr: "ignore" })
|
|
513
|
+
s.flashNotice("Opened web UI")
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
if (key.name === "y" || key.name === "Y") {
|
|
517
|
+
copySelectedIds()
|
|
518
|
+
return
|
|
519
|
+
}
|
|
520
|
+
if (key.name === "c" || key.name === "C") {
|
|
521
|
+
void copyToClipboard(otelServerInstructions())
|
|
522
|
+
.then(() => {
|
|
523
|
+
s.flashNotice("Copied OTEL server details")
|
|
524
|
+
})
|
|
525
|
+
.catch((error) => {
|
|
526
|
+
s.flashNotice(error instanceof Error ? error.message : String(error))
|
|
527
|
+
})
|
|
528
|
+
}
|
|
529
|
+
})
|
|
530
|
+
|
|
531
|
+
return { spanNavActive }
|
|
532
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed a deterministic trace into the SQLite store. Invoked as a child process
|
|
3
|
+
* by the reproducer test so it gets a fresh module graph (config.ts caches the
|
|
4
|
+
* DB path at module-load time).
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* bun run src/ui/waterfallNav.repro.seed.ts
|
|
8
|
+
*
|
|
9
|
+
* Reads MOTEL_OTEL_DB_PATH from the environment.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { Effect } from "effect"
|
|
13
|
+
import { storeRuntime } from "../runtime.ts"
|
|
14
|
+
import { TelemetryStore } from "../services/TelemetryStore.ts"
|
|
15
|
+
|
|
16
|
+
const SERVICE_NAME = "waterfall-repro"
|
|
17
|
+
const TRACE_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
|
|
18
|
+
const ROOT = "1111111111111111"
|
|
19
|
+
const SIB_BEFORE = "2222222222222222"
|
|
20
|
+
const PARENT = "3333333333333333"
|
|
21
|
+
const CHILD_A = "aaaaaaaaaaaaaaaa"
|
|
22
|
+
const CHILD_B = "bbbbbbbbbbbbbbbb"
|
|
23
|
+
const CHILD_C = "cccccccccccccccc"
|
|
24
|
+
const CHILD_D = "dddddddddddddddd"
|
|
25
|
+
const CHILD_E = "eeeeeeeeeeeeeeee"
|
|
26
|
+
const CHILD_F = "ffffffffffffffff"
|
|
27
|
+
const SIB_AFTER = "4444444444444444"
|
|
28
|
+
const TAIL = "5555555555555555"
|
|
29
|
+
const TAIL_CHILD = "6666666666666666"
|
|
30
|
+
const TAIL_GRAND = "7777777777777777"
|
|
31
|
+
|
|
32
|
+
const nowNanos = BigInt(Date.now()) * 1_000_000n
|
|
33
|
+
const ms = (n: number) => String(nowNanos + BigInt(n) * 1_000_000n)
|
|
34
|
+
|
|
35
|
+
const span = (
|
|
36
|
+
spanId: string,
|
|
37
|
+
parent: string | null,
|
|
38
|
+
name: string,
|
|
39
|
+
startMs: number,
|
|
40
|
+
endMs: number,
|
|
41
|
+
) => ({
|
|
42
|
+
traceId: TRACE_ID,
|
|
43
|
+
spanId,
|
|
44
|
+
parentSpanId: parent ?? undefined,
|
|
45
|
+
name,
|
|
46
|
+
kind: 1,
|
|
47
|
+
startTimeUnixNano: ms(startMs),
|
|
48
|
+
endTimeUnixNano: ms(endMs),
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
const program = Effect.flatMap(TelemetryStore.asEffect(), (store) =>
|
|
52
|
+
store.ingestTraces({
|
|
53
|
+
resourceSpans: [
|
|
54
|
+
{
|
|
55
|
+
resource: {
|
|
56
|
+
attributes: [
|
|
57
|
+
{ key: "service.name", value: { stringValue: SERVICE_NAME } },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
scopeSpans: [
|
|
61
|
+
{
|
|
62
|
+
scope: { name: "test-scope" },
|
|
63
|
+
spans: [
|
|
64
|
+
span(ROOT, null, "root.op", 0, 100),
|
|
65
|
+
span(SIB_BEFORE, ROOT, "siblingBefore.op", 1, 2),
|
|
66
|
+
span(PARENT, ROOT, "parent.op", 5, 60),
|
|
67
|
+
span(CHILD_A, PARENT, "childA.op", 6, 8),
|
|
68
|
+
span(CHILD_B, PARENT, "childB.op", 10, 12),
|
|
69
|
+
span(CHILD_C, PARENT, "childC.op", 14, 18),
|
|
70
|
+
span(CHILD_D, PARENT, "childD.op", 20, 25),
|
|
71
|
+
span(CHILD_E, PARENT, "childE.op", 28, 35),
|
|
72
|
+
span(CHILD_F, PARENT, "childF.op", 40, 55),
|
|
73
|
+
span(SIB_AFTER, ROOT, "siblingAfter.op", 65, 68),
|
|
74
|
+
span(TAIL, ROOT, "tail.op", 70, 95),
|
|
75
|
+
span(TAIL_CHILD, TAIL, "tailChild.op", 72, 90),
|
|
76
|
+
span(TAIL_GRAND, TAIL_CHILD, "tailGrandchild.op", 75, 85),
|
|
77
|
+
],
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
}),
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
await storeRuntime.runPromise(program)
|
|
86
|
+
process.exit(0)
|