@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,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
|
+
}
|
package/src/ui/format.ts
ADDED
|
@@ -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
|
+
}
|