@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.
Files changed (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. 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)