@kitlangton/motel 0.2.0 → 0.2.4
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 +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
|
@@ -1,18 +1,27 @@
|
|
|
1
1
|
import { useAtom } from "@effect/atom-react"
|
|
2
2
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
3
3
|
import { config } from "../../config.js"
|
|
4
|
-
import type { LogItem, TraceItem } from "../../domain.ts"
|
|
4
|
+
import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
|
|
5
5
|
import {
|
|
6
6
|
activeAttrKeyAtom,
|
|
7
7
|
activeAttrValueAtom,
|
|
8
|
+
aiCallDetailStateAtom,
|
|
8
9
|
autoRefreshAtom,
|
|
10
|
+
chatDetailChunkIdAtom,
|
|
11
|
+
chatDetailScrollOffsetAtom,
|
|
9
12
|
collapsedSpanIdsAtom,
|
|
10
13
|
detailViewAtom,
|
|
14
|
+
ensureAiCallDetail,
|
|
15
|
+
ensureTraceAttributeKeys,
|
|
11
16
|
filterModeAtom,
|
|
12
17
|
filterTextAtom,
|
|
18
|
+
getCachedAiCallDetail,
|
|
19
|
+
initialAiCallDetailState,
|
|
13
20
|
initialLogState,
|
|
14
21
|
initialServiceLogState,
|
|
15
22
|
initialTraceDetailState,
|
|
23
|
+
invalidateAiCallDetailCache,
|
|
24
|
+
invalidateFacetCaches,
|
|
16
25
|
loadFilteredTraceSummaries,
|
|
17
26
|
loadRecentTraceSummaries,
|
|
18
27
|
loadServiceLogs,
|
|
@@ -22,6 +31,8 @@ import {
|
|
|
22
31
|
logStateAtom,
|
|
23
32
|
persistSelectedService,
|
|
24
33
|
refreshNonceAtom,
|
|
34
|
+
selectedAttrIndexAtom,
|
|
35
|
+
selectedChatChunkIdAtom,
|
|
25
36
|
selectedServiceLogIndexAtom,
|
|
26
37
|
selectedSpanIndexAtom,
|
|
27
38
|
selectedTraceIndexAtom,
|
|
@@ -29,11 +40,70 @@ import {
|
|
|
29
40
|
serviceLogStateAtom,
|
|
30
41
|
showHelpAtom,
|
|
31
42
|
traceDetailStateAtom,
|
|
43
|
+
type TraceSortMode,
|
|
32
44
|
traceSortAtom,
|
|
33
45
|
traceStateAtom,
|
|
34
46
|
} from "../state.ts"
|
|
47
|
+
import { isAiSpan } from "../../domain.ts"
|
|
48
|
+
import { buildChunks, type Chunk } from "../aiChatModel.ts"
|
|
35
49
|
import { parseFilterText } from "../filterParser.ts"
|
|
36
|
-
import { getVisibleSpans } from "../
|
|
50
|
+
import { getVisibleSpans } from "../waterfallModel.ts"
|
|
51
|
+
|
|
52
|
+
const clampSelectionIndex = (index: number, length: number) => {
|
|
53
|
+
if (length === 0) return 0
|
|
54
|
+
return Math.max(0, Math.min(index, length - 1))
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const resolveEffectiveService = (
|
|
58
|
+
services: readonly string[],
|
|
59
|
+
selectedTraceService: string | null,
|
|
60
|
+
) => services.includes(selectedTraceService ?? "")
|
|
61
|
+
? selectedTraceService
|
|
62
|
+
: selectedTraceService ?? services[0] ?? config.otel.serviceName
|
|
63
|
+
|
|
64
|
+
const loadTraceSummariesForService = (
|
|
65
|
+
serviceName: string | null,
|
|
66
|
+
filters: {
|
|
67
|
+
readonly activeAttrKey: string | null
|
|
68
|
+
readonly activeAttrValue: string | null
|
|
69
|
+
readonly debouncedAiText: string | null
|
|
70
|
+
},
|
|
71
|
+
) => {
|
|
72
|
+
if (!serviceName) return Promise.resolve([] as readonly TraceSummaryItem[])
|
|
73
|
+
const hasAttrFilter = Boolean(filters.activeAttrKey && filters.activeAttrValue)
|
|
74
|
+
const hasAiFilter = Boolean(filters.debouncedAiText)
|
|
75
|
+
if (!hasAttrFilter && !hasAiFilter) return loadRecentTraceSummaries(serviceName)
|
|
76
|
+
return loadFilteredTraceSummaries(serviceName, {
|
|
77
|
+
attributeFilters: hasAttrFilter ? { [filters.activeAttrKey as string]: filters.activeAttrValue as string } : undefined,
|
|
78
|
+
aiText: hasAiFilter ? filters.debouncedAiText : null,
|
|
79
|
+
})
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const applyClientTraceFilters = (
|
|
83
|
+
traces: readonly TraceSummaryItem[],
|
|
84
|
+
filterText: string,
|
|
85
|
+
parsedFilter: ReturnType<typeof parseFilterText>,
|
|
86
|
+
) => filterText
|
|
87
|
+
? traces.filter((trace) => {
|
|
88
|
+
if (parsedFilter.errorOnly && trace.errorCount === 0) return false
|
|
89
|
+
if (parsedFilter.operationNeedle && !trace.rootOperationName.toLowerCase().includes(parsedFilter.operationNeedle)) return false
|
|
90
|
+
return true
|
|
91
|
+
})
|
|
92
|
+
: traces
|
|
93
|
+
|
|
94
|
+
const sortTraceSummaries = (traces: readonly TraceSummaryItem[], traceSort: TraceSortMode) => {
|
|
95
|
+
if (traceSort === "recent") return traces
|
|
96
|
+
return [...traces].sort((a, b) => {
|
|
97
|
+
if (traceSort === "slowest") return b.durationMs - a.durationMs
|
|
98
|
+
if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
|
|
99
|
+
return 0
|
|
100
|
+
})
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const getSelectedVisibleSpan = (
|
|
104
|
+
spans: readonly TraceItem["spans"][number][],
|
|
105
|
+
selectedSpanIndex: number | null,
|
|
106
|
+
) => selectedSpanIndex === null ? null : spans[selectedSpanIndex] ?? null
|
|
37
107
|
|
|
38
108
|
export const useTraceScreenData = () => {
|
|
39
109
|
const [traceState, setTraceState] = useAtom(traceStateAtom)
|
|
@@ -45,6 +115,11 @@ export const useTraceScreenData = () => {
|
|
|
45
115
|
const [selectedTraceService, setSelectedTraceService] = useAtom(selectedTraceServiceAtom)
|
|
46
116
|
const [refreshNonce, setRefreshNonce] = useAtom(refreshNonceAtom)
|
|
47
117
|
const [selectedSpanIndex, setSelectedSpanIndex] = useAtom(selectedSpanIndexAtom)
|
|
118
|
+
const [, setSelectedAttrIndex] = useAtom(selectedAttrIndexAtom)
|
|
119
|
+
const [, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
|
|
120
|
+
const [, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
|
|
121
|
+
const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
|
|
122
|
+
const [aiCallDetailState, setAiCallDetailState] = useAtom(aiCallDetailStateAtom)
|
|
48
123
|
const [detailView, setDetailView] = useAtom(detailViewAtom)
|
|
49
124
|
const [showHelp, setShowHelp] = useAtom(showHelpAtom)
|
|
50
125
|
const [collapsedSpanIds, setCollapsedSpanIds] = useAtom(collapsedSpanIdsAtom)
|
|
@@ -91,8 +166,18 @@ export const useTraceScreenData = () => {
|
|
|
91
166
|
serviceLogCacheRef.current.clear()
|
|
92
167
|
traceDetailInflightRef.current.clear()
|
|
93
168
|
traceLogInflightRef.current.clear()
|
|
169
|
+
invalidateFacetCaches()
|
|
170
|
+
invalidateAiCallDetailCache()
|
|
94
171
|
}, [refreshNonce])
|
|
95
172
|
|
|
173
|
+
// Pre-warm the attribute picker facet keys for the currently-selected
|
|
174
|
+
// service so pressing `f` feels instant. Fire-and-forget; errors are
|
|
175
|
+
// surfaced when the user actually opens the picker.
|
|
176
|
+
useEffect(() => {
|
|
177
|
+
if (!selectedTraceService) return
|
|
178
|
+
void ensureTraceAttributeKeys(selectedTraceService).catch(() => {})
|
|
179
|
+
}, [selectedTraceService, refreshNonce])
|
|
180
|
+
|
|
96
181
|
useEffect(() => {
|
|
97
182
|
let cancelled = false
|
|
98
183
|
|
|
@@ -103,28 +188,17 @@ export const useTraceScreenData = () => {
|
|
|
103
188
|
const services = await loadTraceServices()
|
|
104
189
|
if (cancelled) return
|
|
105
190
|
|
|
106
|
-
const effectiveService = services
|
|
107
|
-
? selectedTraceService
|
|
108
|
-
: selectedTraceService ?? services[0] ?? config.otel.serviceName
|
|
191
|
+
const effectiveService = resolveEffectiveService(services, selectedTraceService)
|
|
109
192
|
|
|
110
193
|
if (effectiveService !== selectedTraceService) {
|
|
111
194
|
setSelectedTraceService(effectiveService)
|
|
112
195
|
}
|
|
113
196
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
const hasAiFilter = Boolean(debouncedAiText)
|
|
120
|
-
const traces = effectiveService
|
|
121
|
-
? (hasAttrFilter || hasAiFilter
|
|
122
|
-
? await loadFilteredTraceSummaries(effectiveService, {
|
|
123
|
-
attributeFilters: hasAttrFilter ? { [activeAttrKey as string]: activeAttrValue as string } : undefined,
|
|
124
|
-
aiText: hasAiFilter ? debouncedAiText : null,
|
|
125
|
-
})
|
|
126
|
-
: await loadRecentTraceSummaries(effectiveService))
|
|
127
|
-
: []
|
|
197
|
+
const traces = await loadTraceSummariesForService(effectiveService, {
|
|
198
|
+
activeAttrKey,
|
|
199
|
+
activeAttrValue,
|
|
200
|
+
debouncedAiText,
|
|
201
|
+
})
|
|
128
202
|
if (cancelled) return
|
|
129
203
|
|
|
130
204
|
const prevTraceId = selectedTraceRef.current
|
|
@@ -151,14 +225,18 @@ export const useTraceScreenData = () => {
|
|
|
151
225
|
|
|
152
226
|
useEffect(() => {
|
|
153
227
|
setSelectedTraceIndex((current) => {
|
|
154
|
-
|
|
155
|
-
return Math.max(0, Math.min(current, traceState.data.length - 1))
|
|
228
|
+
return clampSelectionIndex(current, traceState.data.length)
|
|
156
229
|
})
|
|
157
230
|
}, [traceState.data.length, setSelectedTraceIndex])
|
|
158
231
|
|
|
159
232
|
const selectedTraceSummary = traceState.data[selectedTraceIndex] ?? null
|
|
160
233
|
const selectedTraceId = selectedTraceSummary?.traceId ?? null
|
|
161
234
|
const selectedTrace = traceDetailState.traceId === selectedTraceId ? traceDetailState.data : null
|
|
235
|
+
const selectedVisibleSpans = useMemo(
|
|
236
|
+
() => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
|
|
237
|
+
[selectedTrace, collapsedSpanIds],
|
|
238
|
+
)
|
|
239
|
+
const selectedVisibleSpan = getSelectedVisibleSpan(selectedVisibleSpans, selectedSpanIndex)
|
|
162
240
|
selectedTraceRef.current = selectedTraceId
|
|
163
241
|
|
|
164
242
|
const warmTraceDetail = useCallback((traceId: string, hydrateSelection: boolean) => {
|
|
@@ -295,6 +373,72 @@ export const useTraceScreenData = () => {
|
|
|
295
373
|
setSelectedSpanIndex(null)
|
|
296
374
|
}, [selectedTraceId, setCollapsedSpanIds, setSelectedSpanIndex])
|
|
297
375
|
|
|
376
|
+
// Reset the attribute cursor whenever the span selection moves. Without
|
|
377
|
+
// this, drilling from span A (with 34 tags) to span B (with 3 tags)
|
|
378
|
+
// would leave the cursor pointing past the end of B's tag list until
|
|
379
|
+
// the user hit `j`/`k` again.
|
|
380
|
+
useEffect(() => {
|
|
381
|
+
setSelectedAttrIndex(0)
|
|
382
|
+
setChatDetailChunkId(null)
|
|
383
|
+
setChatDetailScrollOffset(0)
|
|
384
|
+
// New span → drop chunk selection and any open detail modal.
|
|
385
|
+
// The effect below will re-select the first chunk once the
|
|
386
|
+
// detail loads.
|
|
387
|
+
setSelectedChatChunkId(null)
|
|
388
|
+
}, [selectedSpanIndex, selectedTraceId, setSelectedAttrIndex, setChatDetailChunkId, setChatDetailScrollOffset, setSelectedChatChunkId])
|
|
389
|
+
|
|
390
|
+
// Load the parsed AI call detail for the currently-selected span when
|
|
391
|
+
// it's an AI span and the user is drilled into L2. Cached module-level
|
|
392
|
+
// so re-entering the chat view for a span we already loaded is free.
|
|
393
|
+
const selectedSpanId = selectedVisibleSpan?.spanId ?? null
|
|
394
|
+
const shouldLoadAiDetail = detailView === "span-detail" && selectedVisibleSpan !== null && isAiSpan(selectedVisibleSpan.tags)
|
|
395
|
+
|
|
396
|
+
useEffect(() => {
|
|
397
|
+
if (!shouldLoadAiDetail || !selectedSpanId) {
|
|
398
|
+
setAiCallDetailState(initialAiCallDetailState)
|
|
399
|
+
return
|
|
400
|
+
}
|
|
401
|
+
const cached = getCachedAiCallDetail(selectedSpanId)
|
|
402
|
+
if (cached !== undefined) {
|
|
403
|
+
setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data: cached, error: null })
|
|
404
|
+
return
|
|
405
|
+
}
|
|
406
|
+
setAiCallDetailState({ status: "loading", spanId: selectedSpanId, data: null, error: null })
|
|
407
|
+
let cancelled = false
|
|
408
|
+
ensureAiCallDetail(selectedSpanId)
|
|
409
|
+
.then((data) => {
|
|
410
|
+
if (cancelled) return
|
|
411
|
+
setAiCallDetailState({ status: "ready", spanId: selectedSpanId, data, error: null })
|
|
412
|
+
})
|
|
413
|
+
.catch((err) => {
|
|
414
|
+
if (cancelled) return
|
|
415
|
+
setAiCallDetailState({
|
|
416
|
+
status: "error",
|
|
417
|
+
spanId: selectedSpanId,
|
|
418
|
+
data: null,
|
|
419
|
+
error: err instanceof Error ? err.message : String(err),
|
|
420
|
+
})
|
|
421
|
+
})
|
|
422
|
+
return () => { cancelled = true }
|
|
423
|
+
}, [shouldLoadAiDetail, selectedSpanId, setAiCallDetailState])
|
|
424
|
+
|
|
425
|
+
// Chunk model — rebuilt whenever the detail payload changes.
|
|
426
|
+
// Width-independent, so this lives here instead of in the view.
|
|
427
|
+
const aiChatChunks = useMemo<readonly Chunk[]>(() => {
|
|
428
|
+
if (!shouldLoadAiDetail || !aiCallDetailState.data) return []
|
|
429
|
+
return buildChunks(aiCallDetailState.data)
|
|
430
|
+
}, [shouldLoadAiDetail, aiCallDetailState.data])
|
|
431
|
+
|
|
432
|
+
// Once chunks are available, pin selection to the first chunk unless
|
|
433
|
+
// the user has already chosen one. Also handles the "chunk list
|
|
434
|
+
// changed and the previous selection disappeared" case.
|
|
435
|
+
useEffect(() => {
|
|
436
|
+
if (aiChatChunks.length === 0) return
|
|
437
|
+
const stillValid = selectedChatChunkId !== null
|
|
438
|
+
&& aiChatChunks.some((c) => c.id === selectedChatChunkId)
|
|
439
|
+
if (!stillValid) setSelectedChatChunkId(aiChatChunks[0]!.id)
|
|
440
|
+
}, [aiChatChunks, selectedChatChunkId, setSelectedChatChunkId])
|
|
441
|
+
|
|
298
442
|
useEffect(() => {
|
|
299
443
|
if (selectedSpanIndex === null) return
|
|
300
444
|
if (!selectedTrace || selectedTrace.spans.length === 0) {
|
|
@@ -302,11 +446,11 @@ export const useTraceScreenData = () => {
|
|
|
302
446
|
setDetailView("waterfall")
|
|
303
447
|
return
|
|
304
448
|
}
|
|
305
|
-
const visibleCount =
|
|
449
|
+
const visibleCount = selectedVisibleSpans.length
|
|
306
450
|
if (selectedSpanIndex >= visibleCount) {
|
|
307
451
|
setSelectedSpanIndex(visibleCount - 1)
|
|
308
452
|
}
|
|
309
|
-
}, [selectedTrace, selectedSpanIndex,
|
|
453
|
+
}, [selectedTrace, selectedSpanIndex, selectedVisibleSpans.length, setDetailView, setSelectedSpanIndex])
|
|
310
454
|
|
|
311
455
|
useEffect(() => {
|
|
312
456
|
const traceId = selectedTraceId
|
|
@@ -375,8 +519,7 @@ export const useTraceScreenData = () => {
|
|
|
375
519
|
|
|
376
520
|
useEffect(() => {
|
|
377
521
|
setSelectedServiceLogIndex((current) => {
|
|
378
|
-
|
|
379
|
-
return Math.max(0, Math.min(current, serviceLogState.data.length - 1))
|
|
522
|
+
return clampSelectionIndex(current, serviceLogState.data.length)
|
|
380
523
|
})
|
|
381
524
|
}, [serviceLogState.data.length, setSelectedServiceLogIndex])
|
|
382
525
|
|
|
@@ -384,21 +527,10 @@ export const useTraceScreenData = () => {
|
|
|
384
527
|
// against already-loaded summaries (no server round-trip). The `:ai`
|
|
385
528
|
// query, by contrast, is applied server-side in the load effect
|
|
386
529
|
// above so we don't need to re-filter it here.
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
return true
|
|
392
|
-
})
|
|
393
|
-
: traceState.data
|
|
394
|
-
|
|
395
|
-
const filteredTraces = traceSort === "recent"
|
|
396
|
-
? preFilterTraces
|
|
397
|
-
: [...preFilterTraces].sort((a, b) => {
|
|
398
|
-
if (traceSort === "slowest") return b.durationMs - a.durationMs
|
|
399
|
-
if (traceSort === "errors") return b.errorCount - a.errorCount || b.startedAt.getTime() - a.startedAt.getTime()
|
|
400
|
-
return 0
|
|
401
|
-
})
|
|
530
|
+
const filteredTraces = useMemo(() => {
|
|
531
|
+
const preFiltered = applyClientTraceFilters(traceState.data, filterText, parsedFilter)
|
|
532
|
+
return sortTraceSummaries(preFiltered, traceSort)
|
|
533
|
+
}, [filterText, parsedFilter, traceSort, traceState.data])
|
|
402
534
|
|
|
403
535
|
useEffect(() => {
|
|
404
536
|
if (!selectedTraceId || filteredTraces.length === 0) return
|
|
@@ -440,5 +572,7 @@ export const useTraceScreenData = () => {
|
|
|
440
572
|
selectedTrace,
|
|
441
573
|
selectedTraceId,
|
|
442
574
|
filteredTraces,
|
|
575
|
+
aiCallDetailState,
|
|
576
|
+
aiChatChunks,
|
|
443
577
|
} as const
|
|
444
578
|
}
|
package/src/ui/atoms.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
2
|
+
import { config } from "../config.ts"
|
|
3
|
+
import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
4
|
+
import type { ThemeName } from "./theme.ts"
|
|
5
|
+
import { readLastService, readLastTheme } from "./persistence.ts"
|
|
6
|
+
|
|
7
|
+
export type LoadStatus = "loading" | "ready" | "error"
|
|
8
|
+
export type DetailView = "waterfall" | "span-detail" | "service-logs"
|
|
9
|
+
|
|
10
|
+
export interface TraceState {
|
|
11
|
+
readonly status: LoadStatus
|
|
12
|
+
readonly services: readonly string[]
|
|
13
|
+
readonly data: readonly TraceSummaryItem[]
|
|
14
|
+
readonly error: string | null
|
|
15
|
+
readonly fetchedAt: Date | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TraceDetailState {
|
|
19
|
+
readonly status: LoadStatus
|
|
20
|
+
readonly traceId: string | null
|
|
21
|
+
readonly data: TraceItem | null
|
|
22
|
+
readonly error: string | null
|
|
23
|
+
readonly fetchedAt: Date | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LogState {
|
|
27
|
+
readonly status: LoadStatus
|
|
28
|
+
readonly traceId: string | null
|
|
29
|
+
readonly data: readonly LogItem[]
|
|
30
|
+
readonly error: string | null
|
|
31
|
+
readonly fetchedAt: Date | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ServiceLogState {
|
|
35
|
+
readonly status: LoadStatus
|
|
36
|
+
readonly serviceName: string | null
|
|
37
|
+
readonly data: readonly LogItem[]
|
|
38
|
+
readonly error: string | null
|
|
39
|
+
readonly fetchedAt: Date | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const initialTraceState: TraceState = {
|
|
43
|
+
status: "loading",
|
|
44
|
+
services: [],
|
|
45
|
+
data: [],
|
|
46
|
+
error: null,
|
|
47
|
+
fetchedAt: null,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const initialLogState: LogState = {
|
|
51
|
+
status: "ready",
|
|
52
|
+
traceId: null,
|
|
53
|
+
data: [],
|
|
54
|
+
error: null,
|
|
55
|
+
fetchedAt: null,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const initialTraceDetailState: TraceDetailState = {
|
|
59
|
+
status: "ready",
|
|
60
|
+
traceId: null,
|
|
61
|
+
data: null,
|
|
62
|
+
error: null,
|
|
63
|
+
fetchedAt: null,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const initialServiceLogState: ServiceLogState = {
|
|
67
|
+
status: "ready",
|
|
68
|
+
serviceName: null,
|
|
69
|
+
data: [],
|
|
70
|
+
error: null,
|
|
71
|
+
fetchedAt: null,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
|
|
75
|
+
export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
|
|
76
|
+
export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
|
|
77
|
+
export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
|
|
78
|
+
export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
79
|
+
export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
80
|
+
export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
|
|
81
|
+
export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
82
|
+
export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
83
|
+
export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
|
|
84
|
+
// Cursor inside the full-screen span content view (detailView === "span-detail").
|
|
85
|
+
// Tracks which span tag is currently selected for copy / drill-in. Reset to 0
|
|
86
|
+
// on each new span so the cursor doesn't point past a shorter tag list.
|
|
87
|
+
export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
88
|
+
export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
|
|
89
|
+
export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
90
|
+
export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
91
|
+
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
92
|
+
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
93
|
+
|
|
94
|
+
// Waterfall-scoped filter: the `/` key while drilled into a trace
|
|
95
|
+
// (viewLevel >= 1) opens this filter instead of the trace-list one.
|
|
96
|
+
// Purely client-side — dims spans whose operation name and attribute
|
|
97
|
+
// values don't contain the needle.
|
|
98
|
+
export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
99
|
+
export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
100
|
+
|
|
101
|
+
// Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
|
|
102
|
+
export type AttrPickerMode = "off" | "keys" | "values"
|
|
103
|
+
export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
|
|
104
|
+
export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
105
|
+
export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
106
|
+
|
|
107
|
+
export interface AttrFacetState {
|
|
108
|
+
readonly status: LoadStatus
|
|
109
|
+
readonly key: string | null
|
|
110
|
+
readonly data: readonly { readonly value: string; readonly count: number }[]
|
|
111
|
+
readonly error: string | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const initialAttrFacetState: AttrFacetState = {
|
|
115
|
+
status: "ready",
|
|
116
|
+
key: null,
|
|
117
|
+
data: [],
|
|
118
|
+
error: null,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
|
|
122
|
+
|
|
123
|
+
// Applied filter (drives trace list query)
|
|
124
|
+
export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
125
|
+
export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
126
|
+
|
|
127
|
+
export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
|
|
128
|
+
|
|
129
|
+
export type TraceSortMode = "recent" | "slowest" | "errors"
|
|
130
|
+
export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
|
|
131
|
+
export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { config } from "../config.ts"
|
|
3
|
+
import { queryRuntime } from "../runtime.ts"
|
|
4
|
+
import { LogQueryService } from "../services/LogQueryService.ts"
|
|
5
|
+
import { TraceQueryService } from "../services/TraceQueryService.ts"
|
|
6
|
+
|
|
7
|
+
export const loadTraceServices = () =>
|
|
8
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
|
|
9
|
+
|
|
10
|
+
export const loadRecentTraceSummaries = (serviceName: string) =>
|
|
11
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Server-side trace summary search. Accepts any combination of:
|
|
15
|
+
*
|
|
16
|
+
* - `attributeFilters` — exact-match span attributes (from the `f` picker)
|
|
17
|
+
* - `aiText` — FTS5-backed search across LLM prompt/response
|
|
18
|
+
* content (AI_FTS_KEYS), from the `:ai <query>`
|
|
19
|
+
* modifier in the `/` filter
|
|
20
|
+
*
|
|
21
|
+
* Both filters compose: when both are set, a trace must match both. When
|
|
22
|
+
* neither is set, callers should prefer `loadRecentTraceSummaries` so
|
|
23
|
+
* the server can skip the search path entirely.
|
|
24
|
+
*/
|
|
25
|
+
export const loadFilteredTraceSummaries = (
|
|
26
|
+
serviceName: string,
|
|
27
|
+
options: {
|
|
28
|
+
readonly attributeFilters?: Readonly<Record<string, string>>
|
|
29
|
+
readonly aiText?: string | null
|
|
30
|
+
},
|
|
31
|
+
) =>
|
|
32
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
|
|
33
|
+
serviceName,
|
|
34
|
+
attributeFilters: options.attributeFilters,
|
|
35
|
+
aiText: options.aiText ?? null,
|
|
36
|
+
limit: config.otel.traceFetchLimit,
|
|
37
|
+
})))
|
|
38
|
+
|
|
39
|
+
export const loadTraceAttributeKeys = (serviceName: string) =>
|
|
40
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
|
|
41
|
+
|
|
42
|
+
export const loadTraceAttributeValues = (serviceName: string, key: string) =>
|
|
43
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Facet cache (drives the `f` attribute filter picker)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface FacetRow {
|
|
50
|
+
readonly value: string
|
|
51
|
+
readonly count: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FacetCacheEntry {
|
|
55
|
+
readonly data: readonly FacetRow[]
|
|
56
|
+
readonly fetchedAt: Date
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const facetKeysCache = new Map<string, FacetCacheEntry>()
|
|
60
|
+
const facetValuesCache = new Map<string, FacetCacheEntry>()
|
|
61
|
+
const facetKeysInflight = new Map<string, Promise<FacetCacheEntry>>()
|
|
62
|
+
const facetValuesInflight = new Map<string, Promise<FacetCacheEntry>>()
|
|
63
|
+
|
|
64
|
+
const valuesKey = (service: string, key: string) => `${service}\u0000${key}`
|
|
65
|
+
|
|
66
|
+
export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
|
|
67
|
+
facetKeysCache.get(service) ?? null
|
|
68
|
+
|
|
69
|
+
export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
|
|
70
|
+
facetValuesCache.get(valuesKey(service, key)) ?? null
|
|
71
|
+
|
|
72
|
+
export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> => {
|
|
73
|
+
const existing = facetKeysInflight.get(service)
|
|
74
|
+
if (existing) return existing
|
|
75
|
+
const request = loadTraceAttributeKeys(service)
|
|
76
|
+
.then((data) => {
|
|
77
|
+
const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
|
|
78
|
+
facetKeysCache.set(service, entry)
|
|
79
|
+
return entry
|
|
80
|
+
})
|
|
81
|
+
.finally(() => {
|
|
82
|
+
facetKeysInflight.delete(service)
|
|
83
|
+
})
|
|
84
|
+
facetKeysInflight.set(service, request)
|
|
85
|
+
return request
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export const ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> => {
|
|
89
|
+
const cacheKey = valuesKey(service, key)
|
|
90
|
+
const existing = facetValuesInflight.get(cacheKey)
|
|
91
|
+
if (existing) return existing
|
|
92
|
+
const request = loadTraceAttributeValues(service, key)
|
|
93
|
+
.then((data) => {
|
|
94
|
+
const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
|
|
95
|
+
facetValuesCache.set(cacheKey, entry)
|
|
96
|
+
return entry
|
|
97
|
+
})
|
|
98
|
+
.finally(() => {
|
|
99
|
+
facetValuesInflight.delete(cacheKey)
|
|
100
|
+
})
|
|
101
|
+
facetValuesInflight.set(cacheKey, request)
|
|
102
|
+
return request
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Called from the refreshNonce effect alongside the trace / log cache clears. */
|
|
106
|
+
export const invalidateFacetCaches = () => {
|
|
107
|
+
facetKeysCache.clear()
|
|
108
|
+
facetValuesCache.clear()
|
|
109
|
+
facetKeysInflight.clear()
|
|
110
|
+
facetValuesInflight.clear()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const loadTraceDetail = (traceId: string) =>
|
|
114
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
|
|
115
|
+
|
|
116
|
+
export const loadTraceLogs = (traceId: string) =>
|
|
117
|
+
queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
|
|
118
|
+
|
|
119
|
+
export const loadServiceLogs = (serviceName: string) =>
|
|
120
|
+
queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { config } from "../config.ts"
|
|
4
|
+
import type { ThemeName } from "./theme.ts"
|
|
5
|
+
|
|
6
|
+
const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
|
|
7
|
+
|
|
8
|
+
export const readLastService = (): string | null => {
|
|
9
|
+
try {
|
|
10
|
+
return readFileSync(lastServicePath, "utf-8").trim() || null
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let lastPersistedService = readLastService()
|
|
17
|
+
|
|
18
|
+
export const persistSelectedService = (service: string) => {
|
|
19
|
+
if (service === lastPersistedService) return
|
|
20
|
+
lastPersistedService = service
|
|
21
|
+
Bun.write(lastServicePath, service).catch(() => {})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
25
|
+
|
|
26
|
+
export const readLastTheme = (): ThemeName => {
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(lastThemePath, "utf-8").trim()
|
|
29
|
+
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
|
|
30
|
+
} catch {
|
|
31
|
+
return "motel-default"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let lastPersistedTheme = readLastTheme()
|
|
36
|
+
|
|
37
|
+
export const persistSelectedTheme = (theme: ThemeName) => {
|
|
38
|
+
if (theme === lastPersistedTheme) return
|
|
39
|
+
lastPersistedTheme = theme
|
|
40
|
+
Bun.write(lastThemePath, theme).catch(() => {})
|
|
41
|
+
}
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { Children } from "react"
|
|
2
3
|
import { colors } from "./theme.ts"
|
|
3
4
|
import { fitCell, truncateText } from "./format.ts"
|
|
4
5
|
import type { DetailView } from "./state.ts"
|
|
@@ -19,19 +20,32 @@ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: stri
|
|
|
19
20
|
</box>
|
|
20
21
|
)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
const collapseFormattingWhitespace = (children: React.ReactNode) => Children.toArray(children).filter((child) => {
|
|
24
|
+
if (typeof child !== "string") return true
|
|
25
|
+
if (child.trim().length > 0) return true
|
|
26
|
+
// OpenTUI preserves JSX indentation/newline text nodes, so strip the
|
|
27
|
+
// formatting-only whitespace between inline spans while keeping any
|
|
28
|
+
// intentional in-band spaces that callers render explicitly.
|
|
29
|
+
return !/[\r\n\t]/.test(child)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => {
|
|
33
|
+
const inlineChildren = collapseFormattingWhitespace(children)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<box height={1}>
|
|
37
|
+
{bg ? (
|
|
38
|
+
<text wrapMode="none" truncate fg={fg} bg={bg}>
|
|
39
|
+
{inlineChildren}
|
|
40
|
+
</text>
|
|
41
|
+
) : (
|
|
42
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
43
|
+
{inlineChildren}
|
|
44
|
+
</text>
|
|
45
|
+
)}
|
|
46
|
+
</box>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
35
49
|
|
|
36
50
|
export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
|
|
37
51
|
const availableRightWidth = Math.max(8, width - left.length - 2)
|