@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,79 @@
1
+ import { useMemo } from "react"
2
+ import { fitCell } from "../format.ts"
3
+ import type { DetailView } from "../state.ts"
4
+
5
+ interface UseAppLayoutInput {
6
+ readonly width: number | undefined
7
+ readonly height: number | undefined
8
+ readonly notice: string | null
9
+ readonly detailView: DetailView
10
+ readonly selectedSpanIndex: number | null
11
+ }
12
+
13
+ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIndex }: UseAppLayoutInput) => useMemo(() => {
14
+ const contentWidth = Math.max(60, width ?? 100)
15
+ const isWideLayout = (width ?? 100) >= 100
16
+ const splitGap = 1
17
+ const sectionPadding = 1
18
+ const traceListHeaderHeight = 1
19
+ const footerNotice = notice ? fitCell(notice, Math.max(24, contentWidth - 2)) : null
20
+ const footerHeight = 1
21
+ const footerFrameHeight = footerHeight > 0 ? 1 + footerHeight : 0
22
+ const frameHeight = 1 + 1 + footerFrameHeight
23
+ const availableContentHeight = Math.max(10, (height ?? 24) - frameHeight)
24
+ const viewLevelForLayout: 0 | 1 | 2 =
25
+ detailView === "span-detail" ? 2 :
26
+ selectedSpanIndex !== null ? 1 :
27
+ 0
28
+ const splitRatio = viewLevelForLayout === 2 ? 0.5 : 0.4
29
+ const leftPaneWidth = isWideLayout ? Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio)) : contentWidth
30
+ const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
31
+ const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
32
+ const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
33
+ const headerFooterWidth = Math.max(24, contentWidth - 2)
34
+ const wideBodyHeight = availableContentHeight
35
+ // TraceDetailsPane + SpanDetailPane both reserve 4 rows for their header
36
+ // (title, op line, meta line, divider), so `bodyLines = paneHeight - 4`
37
+ // makes the pane fill its parent exactly. Using `-5` here left a visible
38
+ // blank row between the last waterfall span and the bottom divider.
39
+ const wideBodyLines = Math.max(8, wideBodyHeight - 4)
40
+ const narrowSplitHeight = Math.max(10, availableContentHeight - 1)
41
+ const narrowListHeight = Math.max(4, Math.min(10, Math.floor(narrowSplitHeight * 0.4), narrowSplitHeight - 9))
42
+ const narrowDetailHeight = narrowSplitHeight - narrowListHeight
43
+ const narrowBodyLines = Math.max(2, narrowDetailHeight - 4)
44
+ const narrowFullBodyLines = Math.max(8, availableContentHeight - 6)
45
+ const wideTraceListBodyHeight = Math.max(1, wideBodyHeight - traceListHeaderHeight)
46
+ const narrowTraceListBodyHeight = Math.max(1, narrowListHeight - traceListHeaderHeight)
47
+ const traceViewportRows = isWideLayout ? wideTraceListBodyHeight : narrowTraceListBodyHeight
48
+ const tracePageSize = Math.max(1, traceViewportRows - 1)
49
+ const spanViewportRows = Math.max(1, (isWideLayout ? wideBodyLines : narrowBodyLines) - 1)
50
+ const spanPageSize = Math.max(1, spanViewportRows - 1)
51
+
52
+ return {
53
+ contentWidth,
54
+ isWideLayout,
55
+ splitGap,
56
+ sectionPadding,
57
+ availableContentHeight,
58
+ viewLevel: viewLevelForLayout,
59
+ footerNotice,
60
+ footerHeight,
61
+ leftPaneWidth,
62
+ rightPaneWidth,
63
+ leftContentWidth,
64
+ rightContentWidth,
65
+ headerFooterWidth,
66
+ wideBodyHeight,
67
+ wideBodyLines,
68
+ narrowListHeight,
69
+ narrowBodyLines,
70
+ narrowFullBodyLines,
71
+ wideTraceListBodyHeight,
72
+ narrowTraceListBodyHeight,
73
+ traceViewportRows,
74
+ tracePageSize,
75
+ spanPageSize,
76
+ } as const
77
+ }, [detailView, height, notice, selectedSpanIndex, width])
78
+
79
+ export type AppLayout = ReturnType<typeof useAppLayout>
@@ -0,0 +1,411 @@
1
+ import { useAtom } from "@effect/atom-react"
2
+ import { useCallback, useEffect, useMemo, useRef } from "react"
3
+ import { config } from "../../config.js"
4
+ import type { LogItem, TraceItem } from "../../domain.ts"
5
+ import {
6
+ autoRefreshAtom,
7
+ collapsedSpanIdsAtom,
8
+ detailViewAtom,
9
+ filterModeAtom,
10
+ filterTextAtom,
11
+ initialLogState,
12
+ initialServiceLogState,
13
+ initialTraceDetailState,
14
+ loadRecentTraceSummaries,
15
+ loadServiceLogs,
16
+ loadTraceDetail,
17
+ loadTraceLogs,
18
+ loadTraceServices,
19
+ logStateAtom,
20
+ persistSelectedService,
21
+ refreshNonceAtom,
22
+ selectedServiceLogIndexAtom,
23
+ selectedSpanIndexAtom,
24
+ selectedTraceIndexAtom,
25
+ selectedTraceServiceAtom,
26
+ serviceLogStateAtom,
27
+ showHelpAtom,
28
+ traceDetailStateAtom,
29
+ traceSortAtom,
30
+ traceStateAtom,
31
+ } from "../state.ts"
32
+ import { getVisibleSpans } from "../Waterfall.tsx"
33
+
34
+ export const useTraceScreenData = () => {
35
+ const [traceState, setTraceState] = useAtom(traceStateAtom)
36
+ const [traceDetailState, setTraceDetailState] = useAtom(traceDetailStateAtom)
37
+ const [logState, setLogState] = useAtom(logStateAtom)
38
+ const [serviceLogState, setServiceLogState] = useAtom(serviceLogStateAtom)
39
+ const [selectedServiceLogIndex, setSelectedServiceLogIndex] = useAtom(selectedServiceLogIndexAtom)
40
+ const [selectedTraceIndex, setSelectedTraceIndex] = useAtom(selectedTraceIndexAtom)
41
+ const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
42
+ const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
43
+ const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
44
+ const [detailView, setDetailView] = useAtom(detailViewAtom)
45
+ const [showHelp, setShowHelp] = useAtom(showHelpAtom)
46
+ const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
47
+ const [autoRefresh] = useAtom(autoRefreshAtom)
48
+ const [filterMode] = useAtom(filterModeAtom)
49
+ const [filterText] = useAtom(filterTextAtom)
50
+ const [traceSort] = useAtom(traceSortAtom)
51
+
52
+ const selectedTraceRef = useRef<string | null>(null)
53
+ const cacheEpochRef = useRef(0)
54
+ const traceDetailCacheRef = useRef(new Map<string, { data: TraceItem | null; fetchedAt: Date }>())
55
+ const traceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
56
+ const serviceLogCacheRef = useRef(new Map<string, { data: readonly LogItem[]; fetchedAt: Date }>())
57
+ const traceDetailInflightRef = useRef(new Map<string, Promise<{ readonly error: string | null }>>())
58
+ const traceLogInflightRef = useRef(new Map<string, Promise<{ readonly error: string | null }>>())
59
+
60
+ useEffect(() => {
61
+ if (selectedTraceService) persistSelectedService(selectedTraceService)
62
+ }, [selectedTraceService])
63
+
64
+ useEffect(() => {
65
+ if (!autoRefresh) return
66
+ const id = setInterval(() => setRefreshNonce((n) => n + 1), 5000)
67
+ return () => clearInterval(id)
68
+ }, [autoRefresh, setRefreshNonce])
69
+
70
+ useEffect(() => {
71
+ cacheEpochRef.current += 1
72
+ traceDetailCacheRef.current.clear()
73
+ traceLogCacheRef.current.clear()
74
+ serviceLogCacheRef.current.clear()
75
+ traceDetailInflightRef.current.clear()
76
+ traceLogInflightRef.current.clear()
77
+ }, [refreshNonce])
78
+
79
+ useEffect(() => {
80
+ let cancelled = false
81
+
82
+ const load = async () => {
83
+ setTraceState((current) => ({ ...current, status: current.fetchedAt === null ? "loading" : "ready", error: null }))
84
+
85
+ try {
86
+ const services = await loadTraceServices()
87
+ if (cancelled) return
88
+
89
+ const effectiveService = services.includes(selectedTraceService ?? "")
90
+ ? selectedTraceService
91
+ : selectedTraceService ?? services[0] ?? config.otel.serviceName
92
+
93
+ if (effectiveService !== selectedTraceService) {
94
+ setSelectedTraceService(effectiveService)
95
+ }
96
+
97
+ const traces = effectiveService ? await loadRecentTraceSummaries(effectiveService) : []
98
+ if (cancelled) return
99
+
100
+ const prevTraceId = selectedTraceRef.current
101
+ setTraceState({ status: "ready", services, data: traces, error: null, fetchedAt: new Date() })
102
+ if (prevTraceId) {
103
+ const newIndex = traces.findIndex((t) => t.traceId === prevTraceId)
104
+ if (newIndex >= 0) setSelectedTraceIndex(newIndex)
105
+ }
106
+ } catch (error) {
107
+ if (cancelled) return
108
+ setTraceState((current) => ({
109
+ ...current,
110
+ status: "error",
111
+ error: error instanceof Error ? error.message : String(error),
112
+ }))
113
+ }
114
+ }
115
+
116
+ void load()
117
+ return () => {
118
+ cancelled = true
119
+ }
120
+ }, [refreshNonce, selectedTraceService, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
121
+
122
+ useEffect(() => {
123
+ setSelectedTraceIndex((current) => {
124
+ if (traceState.data.length === 0) return 0
125
+ return Math.max(0, Math.min(current, traceState.data.length - 1))
126
+ })
127
+ }, [traceState.data.length, setSelectedTraceIndex])
128
+
129
+ const selectedTraceSummary = traceState.data[selectedTraceIndex] ?? null
130
+ const selectedTraceId = selectedTraceSummary?.traceId ?? null
131
+ const selectedTrace = traceDetailState.traceId === selectedTraceId ? traceDetailState.data : null
132
+ selectedTraceRef.current = selectedTraceId
133
+
134
+ const warmTraceDetail = useCallback((traceId: string, hydrateSelection: boolean) => {
135
+ const cached = traceDetailCacheRef.current.get(traceId)
136
+ if (cached) {
137
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
138
+ setTraceDetailState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
139
+ }
140
+ return Promise.resolve({ error: null })
141
+ }
142
+
143
+ const existing = traceDetailInflightRef.current.get(traceId)
144
+ if (existing) {
145
+ if (hydrateSelection) {
146
+ void existing.then(({ error }) => {
147
+ if (selectedTraceRef.current !== traceId) return
148
+ const ready = traceDetailCacheRef.current.get(traceId)
149
+ if (ready) {
150
+ setTraceDetailState({ status: "ready", traceId, data: ready.data, error: null, fetchedAt: ready.fetchedAt })
151
+ return
152
+ }
153
+ if (error) {
154
+ setTraceDetailState({ status: "error", traceId, data: null, error, fetchedAt: null })
155
+ }
156
+ })
157
+ }
158
+ return existing
159
+ }
160
+
161
+ const epoch = cacheEpochRef.current
162
+ const request = loadTraceDetail(traceId)
163
+ .then((trace) => {
164
+ if (cacheEpochRef.current !== epoch) return { error: null }
165
+ const fetchedAt = new Date()
166
+ traceDetailCacheRef.current.set(traceId, { data: trace, fetchedAt })
167
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
168
+ setTraceDetailState({ status: "ready", traceId, data: trace, error: null, fetchedAt })
169
+ }
170
+ return { error: null }
171
+ })
172
+ .catch((error) => {
173
+ const message = error instanceof Error ? error.message : String(error)
174
+ if (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
175
+ setTraceDetailState({ status: "error", traceId, data: null, error: message, fetchedAt: null })
176
+ }
177
+ return { error: message }
178
+ })
179
+ .finally(() => {
180
+ traceDetailInflightRef.current.delete(traceId)
181
+ })
182
+
183
+ traceDetailInflightRef.current.set(traceId, request)
184
+ return request
185
+ }, [setTraceDetailState])
186
+
187
+ const warmTraceLogs = useCallback((traceId: string, hydrateSelection: boolean) => {
188
+ const cached = traceLogCacheRef.current.get(traceId)
189
+ if (cached) {
190
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
191
+ setLogState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
192
+ }
193
+ return Promise.resolve({ error: null })
194
+ }
195
+
196
+ const existing = traceLogInflightRef.current.get(traceId)
197
+ if (existing) {
198
+ if (hydrateSelection) {
199
+ void existing.then(({ error }) => {
200
+ if (selectedTraceRef.current !== traceId) return
201
+ const ready = traceLogCacheRef.current.get(traceId)
202
+ if (ready) {
203
+ setLogState({ status: "ready", traceId, data: ready.data, error: null, fetchedAt: ready.fetchedAt })
204
+ return
205
+ }
206
+ if (error) {
207
+ setLogState({ status: "error", traceId, data: [], error, fetchedAt: null })
208
+ }
209
+ })
210
+ }
211
+ return existing
212
+ }
213
+
214
+ const epoch = cacheEpochRef.current
215
+ const request = loadTraceLogs(traceId)
216
+ .then((logs) => {
217
+ if (cacheEpochRef.current !== epoch) return { error: null }
218
+ const fetchedAt = new Date()
219
+ traceLogCacheRef.current.set(traceId, { data: logs, fetchedAt })
220
+ if (hydrateSelection && selectedTraceRef.current === traceId) {
221
+ setLogState({ status: "ready", traceId, data: logs, error: null, fetchedAt })
222
+ }
223
+ return { error: null }
224
+ })
225
+ .catch((error) => {
226
+ const message = error instanceof Error ? error.message : String(error)
227
+ if (cacheEpochRef.current === epoch && hydrateSelection && selectedTraceRef.current === traceId) {
228
+ setLogState({ status: "error", traceId, data: [], error: message, fetchedAt: null })
229
+ }
230
+ return { error: message }
231
+ })
232
+ .finally(() => {
233
+ traceLogInflightRef.current.delete(traceId)
234
+ })
235
+
236
+ traceLogInflightRef.current.set(traceId, request)
237
+ return request
238
+ }, [setLogState])
239
+
240
+ useEffect(() => {
241
+ if (!selectedTraceId) {
242
+ setTraceDetailState(initialTraceDetailState)
243
+ return
244
+ }
245
+
246
+ const cached = traceDetailCacheRef.current.get(selectedTraceId)
247
+ if (cached) {
248
+ setTraceDetailState({ status: "ready", traceId: selectedTraceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
249
+ return
250
+ }
251
+
252
+ setTraceDetailState((current) => ({
253
+ status: current.traceId === selectedTraceId && current.fetchedAt !== null ? "ready" : "loading",
254
+ traceId: selectedTraceId,
255
+ data: current.traceId === selectedTraceId ? current.data : null,
256
+ error: null,
257
+ fetchedAt: current.traceId === selectedTraceId ? current.fetchedAt : null,
258
+ }))
259
+
260
+ void warmTraceDetail(selectedTraceId, true)
261
+ }, [refreshNonce, selectedTraceId, setTraceDetailState, warmTraceDetail])
262
+
263
+ useEffect(() => {
264
+ setCollapsedSpanIds(new Set())
265
+ setSelectedSpanIndex(null)
266
+ }, [selectedTraceId, setCollapsedSpanIds, setSelectedSpanIndex])
267
+
268
+ useEffect(() => {
269
+ if (selectedSpanIndex === null) return
270
+ if (!selectedTrace || selectedTrace.spans.length === 0) {
271
+ setSelectedSpanIndex(null)
272
+ setDetailView("waterfall")
273
+ return
274
+ }
275
+ const visibleCount = getVisibleSpans(selectedTrace.spans, collapsedSpanIds).length
276
+ if (selectedSpanIndex >= visibleCount) {
277
+ setSelectedSpanIndex(visibleCount - 1)
278
+ }
279
+ }, [selectedTrace, selectedSpanIndex, collapsedSpanIds, setDetailView, setSelectedSpanIndex])
280
+
281
+ useEffect(() => {
282
+ const traceId = selectedTraceId
283
+ if (!traceId) {
284
+ setLogState(initialLogState)
285
+ return
286
+ }
287
+
288
+ const cached = traceLogCacheRef.current.get(traceId)
289
+ if (cached) {
290
+ setLogState({ status: "ready", traceId, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
291
+ return
292
+ }
293
+
294
+ setLogState((current) => ({
295
+ status: current.traceId === traceId && current.fetchedAt !== null ? "ready" : "loading",
296
+ traceId,
297
+ data: current.traceId === traceId ? current.data : [],
298
+ error: null,
299
+ fetchedAt: current.traceId === traceId ? current.fetchedAt : null,
300
+ }))
301
+
302
+ void warmTraceLogs(traceId, true)
303
+ }, [refreshNonce, selectedTraceId, setLogState, warmTraceLogs])
304
+
305
+ useEffect(() => {
306
+ if (detailView !== "service-logs") return
307
+ const serviceName = selectedTraceService
308
+ if (!serviceName) {
309
+ setServiceLogState(initialServiceLogState)
310
+ return
311
+ }
312
+
313
+ const cached = serviceLogCacheRef.current.get(serviceName)
314
+ if (cached) {
315
+ setServiceLogState({ status: "ready", serviceName, data: cached.data, error: null, fetchedAt: cached.fetchedAt })
316
+ return
317
+ }
318
+
319
+ let cancelled = false
320
+ setServiceLogState((current) => ({
321
+ status: current.serviceName === serviceName && current.fetchedAt !== null ? "ready" : "loading",
322
+ serviceName,
323
+ data: current.serviceName === serviceName ? current.data : [],
324
+ error: null,
325
+ fetchedAt: current.serviceName === serviceName ? current.fetchedAt : null,
326
+ }))
327
+
328
+ void (async () => {
329
+ try {
330
+ const logs = await loadServiceLogs(serviceName)
331
+ const fetchedAt = new Date()
332
+ serviceLogCacheRef.current.set(serviceName, { data: logs, fetchedAt })
333
+ if (cancelled) return
334
+ setServiceLogState({ status: "ready", serviceName, data: logs, error: null, fetchedAt })
335
+ } catch (error) {
336
+ if (cancelled) return
337
+ setServiceLogState({ status: "error", serviceName, data: [], error: error instanceof Error ? error.message : String(error), fetchedAt: null })
338
+ }
339
+ })()
340
+
341
+ return () => {
342
+ cancelled = true
343
+ }
344
+ }, [detailView, refreshNonce, selectedTraceService, setServiceLogState])
345
+
346
+ useEffect(() => {
347
+ setSelectedServiceLogIndex((current) => {
348
+ if (serviceLogState.data.length === 0) return 0
349
+ return Math.max(0, Math.min(current, serviceLogState.data.length - 1))
350
+ })
351
+ }, [serviceLogState.data.length, setSelectedServiceLogIndex])
352
+
353
+ const preFilterTraces = filterText
354
+ ? traceState.data.filter((trace) => {
355
+ const needle = filterText.toLowerCase()
356
+ const errorOnly = needle.includes(":error")
357
+ const textNeedle = needle.replace(":error", "").trim()
358
+ if (errorOnly && trace.errorCount === 0) return false
359
+ if (textNeedle && !trace.rootOperationName.toLowerCase().includes(textNeedle)) return false
360
+ return true
361
+ })
362
+ : traceState.data
363
+
364
+ const filteredTraces = traceSort === "recent"
365
+ ? preFilterTraces
366
+ : [...preFilterTraces].sort((a, b) => {
367
+ if (traceSort === "slowest") return b.durationMs - a.durationMs
368
+ if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
369
+ return 0
370
+ })
371
+
372
+ useEffect(() => {
373
+ if (!selectedTraceId || filteredTraces.length === 0) return
374
+ const currentIndex = filteredTraces.findIndex((trace) => trace.traceId === selectedTraceId)
375
+ if (currentIndex < 0) return
376
+
377
+ for (const offset of [-1, 1] as const) {
378
+ const neighborId = filteredTraces[currentIndex + offset]?.traceId
379
+ if (!neighborId) continue
380
+ void warmTraceDetail(neighborId, false)
381
+ void warmTraceLogs(neighborId, false)
382
+ }
383
+ }, [filteredTraces, selectedTraceId, warmTraceDetail, warmTraceLogs])
384
+
385
+ return {
386
+ traceState,
387
+ traceDetailState,
388
+ logState,
389
+ serviceLogState,
390
+ selectedServiceLogIndex,
391
+ setSelectedServiceLogIndex,
392
+ selectedTraceIndex,
393
+ setSelectedTraceIndex,
394
+ selectedTraceService,
395
+ selectedSpanIndex,
396
+ setSelectedSpanIndex,
397
+ detailView,
398
+ setDetailView,
399
+ showHelp,
400
+ setShowHelp,
401
+ collapsedSpanIds,
402
+ autoRefresh,
403
+ filterMode,
404
+ filterText,
405
+ traceSort,
406
+ selectedTraceSummary,
407
+ selectedTrace,
408
+ selectedTraceId,
409
+ filteredTraces,
410
+ } as const
411
+ }
@@ -0,0 +1,119 @@
1
+ import { resolveOtelUrl } from "../config.ts"
2
+ import type { LogItem } from "../domain.ts"
3
+ import { colors } from "./theme.ts"
4
+
5
+ export const truncateText = (text: string, width: number) => {
6
+ if (width <= 0) return ""
7
+ if (text.length <= width) return text
8
+ if (width <= 3) return text.slice(0, width)
9
+ return `${text.slice(0, width - 3)}...`
10
+ }
11
+
12
+ export const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
13
+ const trimmed = truncateText(text, width)
14
+ return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
15
+ }
16
+
17
+ export const formatShortDate = (date: Date) => date.toLocaleDateString("en-US", { month: "numeric", day: "numeric" })
18
+
19
+ export const formatTimestamp = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase()
20
+
21
+ export const formatDuration = (durationMs: number) => {
22
+ const trimDecimal = (value: string) => value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")
23
+
24
+ if (durationMs >= 10_000) return `${Math.round(durationMs / 1000)}s`
25
+ if (durationMs >= 1000) return `${trimDecimal((durationMs / 1000).toFixed(1))}s`
26
+ if (durationMs >= 100) return `${Math.round(durationMs)}ms`
27
+ if (durationMs >= 10) return `${trimDecimal(durationMs.toFixed(1))}ms`
28
+ return `${trimDecimal(durationMs.toFixed(2))}ms`
29
+ }
30
+
31
+ export const lifecycleLabel = (value: { readonly isRunning: boolean }) => (value.isRunning ? "open" : "closed")
32
+
33
+ export const relativeTime = (date: Date) => {
34
+ const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))
35
+ if (seconds < 60) return `${seconds}s`
36
+ if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
37
+ if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
38
+ return `${Math.floor(seconds / 86_400)}d`
39
+ }
40
+
41
+ export const formatLogTimestamp = (timestamp: Date) => `${formatShortDate(timestamp)} ${formatTimestamp(timestamp)}`
42
+
43
+ export const logHeadline = (body: string) => body.split(/\r?\n/, 1)[0]?.replace(/\s+/g, " ").trim() || ""
44
+
45
+ export const wrapTextLines = (text: string, width: number, maxLines: number) => {
46
+ const normalized = text.replace(/\r/g, "")
47
+ const hardLines = normalized.split("\n")
48
+ const lines: string[] = []
49
+
50
+ for (const hardLine of hardLines) {
51
+ let remaining = hardLine
52
+ if (remaining.length === 0) {
53
+ lines.push("")
54
+ if (lines.length >= maxLines) return lines
55
+ continue
56
+ }
57
+ while (remaining.length > 0) {
58
+ lines.push(remaining.slice(0, width))
59
+ remaining = remaining.slice(width)
60
+ if (lines.length >= maxLines) {
61
+ if (remaining.length > 0) {
62
+ lines[maxLines - 1] = truncateText(lines[maxLines - 1]!, width)
63
+ }
64
+ return lines
65
+ }
66
+ }
67
+ }
68
+
69
+ return lines.slice(0, maxLines)
70
+ }
71
+
72
+ export const traceIndicator = (trace: { readonly errorCount: number }) => (trace.errorCount > 0 ? "!" : "\u00b7")
73
+ export const traceIndicatorColor = (trace: { readonly errorCount: number }) => (trace.errorCount > 0 ? colors.error : colors.passing)
74
+ export const traceRowId = (traceId: string) => `trace-row-${traceId}`
75
+
76
+ export const logSeverityColor = (severity: string) => {
77
+ if (severity.startsWith("ERROR") || severity.startsWith("FATAL")) return colors.error
78
+ if (severity.startsWith("WARN")) return colors.warning
79
+ return colors.count
80
+ }
81
+
82
+ export const relevantLogAttributes = (log: LogItem) =>
83
+ Object.entries(log.attributes).filter(([key]) =>
84
+ ![
85
+ "deployment.environment.name",
86
+ "service.instance.id",
87
+ "service.name",
88
+ "telemetry.sdk.name",
89
+ "telemetry.sdk.language",
90
+ "fiberId",
91
+ "spanId",
92
+ "traceId",
93
+ ].includes(key),
94
+ )
95
+
96
+ export const traceUiUrl = (traceId: string) => resolveOtelUrl(`/trace/${traceId}`)
97
+ export const webUiUrl = () => resolveOtelUrl(`/traces`)
98
+
99
+ export const copyToClipboard = async (value: string) => {
100
+ const proc = Bun.spawn({
101
+ cmd: ["pbcopy"],
102
+ stdin: "pipe",
103
+ stdout: "ignore",
104
+ stderr: "pipe",
105
+ })
106
+
107
+ if (!proc.stdin) {
108
+ throw new Error("Clipboard is not available")
109
+ }
110
+
111
+ proc.stdin.write(value)
112
+ proc.stdin.end()
113
+
114
+ const exitCode = await proc.exited
115
+ if (exitCode !== 0) {
116
+ const stderr = await new Response(proc.stderr).text()
117
+ throw new Error(stderr.trim() || "Could not copy OTEL server details")
118
+ }
119
+ }