@kitlangton/motel 0.2.4 → 0.2.6
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 +23 -8
- package/README.md +13 -2
- package/package.json +35 -19
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +12 -5
- package/src/StartupGate.tsx +289 -0
- package/src/cli.ts +15 -16
- package/src/config.ts +7 -1
- package/src/daemon.test.ts +332 -51
- package/src/daemon.ts +105 -153
- package/src/httpApi.ts +1 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +9 -2
- package/src/localServer.ts +194 -313
- package/src/mcp.ts +2 -1
- package/src/motel.ts +0 -2
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +20 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/registry.ts +37 -11
- package/src/runtime.ts +2 -6
- package/src/services/AsyncIngest.ts +22 -8
- package/src/services/LogQueryService.ts +13 -27
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +546 -231
- package/src/services/TraceQueryService.ts +22 -56
- package/src/services/ingestRpc.ts +2 -4
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +5 -8
- package/src/startupBench.ts +19 -0
- package/src/storybook/aiChatStory.tsx +1 -1
- package/src/telemetry.test.ts +307 -41
- package/src/ui/AiChatView.tsx +1 -1
- package/src/ui/AttrFilterModal.tsx +1 -1
- package/src/ui/ServiceLogs.tsx +10 -7
- package/src/ui/SpanContentView.tsx +24 -21
- package/src/ui/TraceDetailsPane.tsx +1 -1
- package/src/ui/TraceList.tsx +1 -1
- package/src/ui/aiState.ts +10 -22
- package/src/ui/app/TraceWorkspace.tsx +2 -1
- package/src/ui/app/useAppLayout.ts +1 -1
- package/src/ui/app/useTraceScreenData.ts +35 -23
- package/src/ui/atoms.ts +1 -1
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/loaders.ts +34 -53
- package/src/ui/persistence.ts +3 -3
- package/src/ui/primitives.tsx +1 -1
- package/src/ui/state.ts +2 -0
- package/src/ui/theme.ts +7 -5
- package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
- package/src/ui/traceSortNav.repro.seed.ts +1 -1
- package/src/ui/traceSortNav.repro.test.ts +12 -2
- package/src/ui/useAttrFilterPicker.ts +10 -8
- package/src/ui/useKeyboardNav.ts +28 -5
- package/src/ui/waterfallNav.repro.seed.ts +1 -1
- package/src/ui/waterfallNav.repro.test.ts +16 -8
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-M86tcih5.js +22 -0
- package/web/dist/index.html +2 -2
- package/web/dist/assets/index-DnyVo03x.js +0 -27
- package/web/dist/assets/index-DzuHNBGV.css +0 -2
package/src/ui/ServiceLogs.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef } from "react"
|
|
|
3
3
|
import type { LogItem } from "../domain.ts"
|
|
4
4
|
import { fitCell, formatLogTimestamp, formatTimestamp, logHeadline, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
|
|
5
5
|
import { Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
6
|
-
import type { ServiceLogState } from "./
|
|
6
|
+
import type { ServiceLogState } from "./atoms.ts"
|
|
7
7
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
8
8
|
|
|
9
9
|
export const ServiceLogsView = ({
|
|
@@ -26,6 +26,15 @@ export const ServiceLogsView = ({
|
|
|
26
26
|
const traceWidth = 8
|
|
27
27
|
const messageWidth = Math.max(16, contentWidth - timeWidth - levelWidth - traceWidth - 3)
|
|
28
28
|
|
|
29
|
+
// Hooks must be called unconditionally — keep them above the early
|
|
30
|
+
// returns. The data-dependent values below all tolerate a 0-length
|
|
31
|
+
// data array, so this stays correct in the empty/loading/error cases.
|
|
32
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
33
|
+
const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
|
|
36
|
+
}, [safeSelectedIndex])
|
|
37
|
+
|
|
29
38
|
if (logsState.status === "loading" && logsState.data.length === 0) {
|
|
30
39
|
return <PlainLine text="Loading recent service logs..." fg={colors.count} />
|
|
31
40
|
}
|
|
@@ -38,8 +47,6 @@ export const ServiceLogsView = ({
|
|
|
38
47
|
return <PlainLine text={`No logs captured yet for service ${serviceName ?? "unknown"}.`} fg={colors.muted} />
|
|
39
48
|
}
|
|
40
49
|
|
|
41
|
-
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
42
|
-
const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
|
|
43
50
|
const selectedLog = logsState.data[safeSelectedIndex] ?? null
|
|
44
51
|
const detailWidth = Math.max(16, contentWidth - 2)
|
|
45
52
|
const detailBodyLines = selectedLog ? wrapTextLines(selectedLog.body, detailWidth, 6) : []
|
|
@@ -47,10 +54,6 @@ export const ServiceLogsView = ({
|
|
|
47
54
|
const detailHeight = selectedLog ? 3 + detailBodyLines.length + detailAttributeLines.length : 0
|
|
48
55
|
const listHeight = Math.max(4, bodyLines - detailHeight - 1)
|
|
49
56
|
|
|
50
|
-
useLayoutEffect(() => {
|
|
51
|
-
scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
|
|
52
|
-
}, [safeSelectedIndex])
|
|
53
|
-
|
|
54
57
|
return (
|
|
55
58
|
<box flexDirection="column">
|
|
56
59
|
{selectedLog ? (
|
|
@@ -47,6 +47,25 @@ export const SpanContentView = ({
|
|
|
47
47
|
readonly paneWidth: number
|
|
48
48
|
readonly selectedAttrIndex: number
|
|
49
49
|
}) => {
|
|
50
|
+
const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
|
|
51
|
+
const tags = span?.tags
|
|
52
|
+
|
|
53
|
+
// Block layout is memoised on tags identity — otherwise every j/k press
|
|
54
|
+
// would rewrap every attribute's value even though only the highlight
|
|
55
|
+
// moved. Hooks must be called unconditionally, so this lives above the
|
|
56
|
+
// `!span` early return and the memo handles the empty case itself.
|
|
57
|
+
const blocks = useMemo<readonly AttrBlock[]>(() => {
|
|
58
|
+
if (!tags) return []
|
|
59
|
+
return Object.entries(tags).map(([key, value]) => {
|
|
60
|
+
// Word-wrap with a generous line cap — the view is scrollable so
|
|
61
|
+
// we can afford to show the whole value rather than forcing an
|
|
62
|
+
// ellipsis. 200 lines covers enormous LLM prompts without
|
|
63
|
+
// blowing up the render tree.
|
|
64
|
+
const valueLines = wrapTextLines(value, valueWrapWidth, 200)
|
|
65
|
+
return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
|
|
66
|
+
})
|
|
67
|
+
}, [tags, valueWrapWidth])
|
|
68
|
+
|
|
50
69
|
if (!span) {
|
|
51
70
|
return (
|
|
52
71
|
<box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
|
|
@@ -61,25 +80,9 @@ export const SpanContentView = ({
|
|
|
61
80
|
)
|
|
62
81
|
}
|
|
63
82
|
|
|
64
|
-
const
|
|
65
|
-
const selected = Math.max(0, Math.min(selectedAttrIndex, entries.length - 1))
|
|
66
|
-
const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
|
|
83
|
+
const selected = Math.max(0, Math.min(selectedAttrIndex, blocks.length - 1))
|
|
67
84
|
const aiFlag = isAiSpan(span.tags)
|
|
68
85
|
|
|
69
|
-
// Block layout is memoised on entries identity — otherwise every j/k
|
|
70
|
-
// press would rewrap every attribute's value even though only the
|
|
71
|
-
// highlight moved.
|
|
72
|
-
const blocks = useMemo<readonly AttrBlock[]>(() => {
|
|
73
|
-
return entries.map(([key, value]) => {
|
|
74
|
-
// Word-wrap with a generous line cap — the view is scrollable so
|
|
75
|
-
// we can afford to show the whole value rather than forcing an
|
|
76
|
-
// ellipsis. 200 lines covers enormous LLM prompts without
|
|
77
|
-
// blowing up the render tree.
|
|
78
|
-
const valueLines = wrapTextLines(value, valueWrapWidth, 200)
|
|
79
|
-
return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
|
|
80
|
-
})
|
|
81
|
-
}, [entries, valueWrapWidth])
|
|
82
|
-
|
|
83
86
|
// Viewport: pick the contiguous window of blocks that (a) fits inside
|
|
84
87
|
// bodyLines and (b) contains the selected block. We find the first
|
|
85
88
|
// start index whose accumulated row count from there through `selected`
|
|
@@ -137,14 +140,14 @@ export const SpanContentView = ({
|
|
|
137
140
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
138
141
|
<span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
|
|
139
142
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
140
|
-
<span fg={colors.count}>{`${
|
|
143
|
+
<span fg={colors.count}>{`${blocks.length} tags`}</span>
|
|
141
144
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
142
|
-
<span fg={colors.muted}>{`${selected + 1}/${
|
|
145
|
+
<span fg={colors.muted}>{`${selected + 1}/${blocks.length}`}</span>
|
|
143
146
|
</TextLine>
|
|
144
147
|
</box>
|
|
145
148
|
<Divider width={paneWidth} />
|
|
146
149
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
147
|
-
{
|
|
150
|
+
{blocks.length === 0 ? (
|
|
148
151
|
<PlainLine text="No tags on this span." fg={colors.muted} />
|
|
149
152
|
) : (
|
|
150
153
|
visible.map((block, offset) => {
|
|
@@ -165,7 +168,7 @@ export const SpanContentView = ({
|
|
|
165
168
|
</TextLine>
|
|
166
169
|
) : (
|
|
167
170
|
block.valueLines.map((line, i) => (
|
|
168
|
-
<TextLine key={i}>
|
|
171
|
+
<TextLine key={`${block.key}-${i}`}>
|
|
169
172
|
<span fg={colors.separator}>{VALUE_INDENT}</span>
|
|
170
173
|
<span fg={isSelected ? colors.text : colors.muted}>{line}</span>
|
|
171
174
|
</TextLine>
|
|
@@ -5,7 +5,7 @@ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./pr
|
|
|
5
5
|
import { WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
6
|
import { computeMatchingSpanIds } from "./waterfallFilter.ts"
|
|
7
7
|
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
8
|
-
import type { LoadStatus } from "./
|
|
8
|
+
import type { LoadStatus } from "./atoms.ts"
|
|
9
9
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
10
10
|
|
|
11
11
|
/**
|
package/src/ui/TraceList.tsx
CHANGED
|
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, useState } from "react"
|
|
|
3
3
|
import type { TraceSummaryItem } from "../domain.ts"
|
|
4
4
|
import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
|
|
5
5
|
import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
|
|
6
|
-
import type { LoadStatus } from "./
|
|
6
|
+
import type { LoadStatus } from "./atoms.ts"
|
|
7
7
|
import { colors } from "./theme.ts"
|
|
8
8
|
|
|
9
9
|
const getTraceRowLayout = (contentWidth: number) => {
|
package/src/ui/aiState.ts
CHANGED
|
@@ -2,8 +2,9 @@ import { Effect } from "effect"
|
|
|
2
2
|
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
3
3
|
import type { AiCallDetail } from "../domain.ts"
|
|
4
4
|
import { queryRuntime } from "../runtime.ts"
|
|
5
|
-
import {
|
|
5
|
+
import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
|
|
6
6
|
import type { LoadStatus } from "./atoms.ts"
|
|
7
|
+
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
7
8
|
|
|
8
9
|
// AI chat view (full-screen when drilled into an `isAiSpan` span).
|
|
9
10
|
// ---------------------------------------------------------------------
|
|
@@ -37,35 +38,22 @@ export const initialAiCallDetailState: AiCallDetailState = {
|
|
|
37
38
|
export const aiCallDetailStateAtom = Atom.make(initialAiCallDetailState).pipe(Atom.keepAlive)
|
|
38
39
|
|
|
39
40
|
export const loadAiCallDetail = (spanId: string) =>
|
|
40
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
41
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getAiCall(spanId)))
|
|
41
42
|
|
|
42
43
|
// AI call detail cache: the `ai.prompt` payload can easily be 50KB+ and
|
|
43
44
|
// we don't want to re-hit SQLite every time j/k moves the selection
|
|
44
45
|
// between adjacent AI spans. Cleared alongside the other per-refresh
|
|
45
46
|
// caches in `useTraceScreenData`.
|
|
46
|
-
const
|
|
47
|
-
|
|
47
|
+
const aiCallDetailLoader = makeCachedLoader<string, AiCallDetail | null>({
|
|
48
|
+
load: loadAiCallDetail,
|
|
49
|
+
})
|
|
48
50
|
|
|
49
51
|
export const getCachedAiCallDetail = (spanId: string): AiCallDetail | null | undefined =>
|
|
50
|
-
|
|
52
|
+
aiCallDetailLoader.get(spanId)
|
|
51
53
|
|
|
52
|
-
export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> =>
|
|
53
|
-
|
|
54
|
-
const existing = aiCallDetailInflight.get(spanId)
|
|
55
|
-
if (existing) return existing
|
|
56
|
-
const request = loadAiCallDetail(spanId)
|
|
57
|
-
.then((data) => {
|
|
58
|
-
aiCallDetailCache.set(spanId, data)
|
|
59
|
-
return data
|
|
60
|
-
})
|
|
61
|
-
.finally(() => {
|
|
62
|
-
aiCallDetailInflight.delete(spanId)
|
|
63
|
-
})
|
|
64
|
-
aiCallDetailInflight.set(spanId, request)
|
|
65
|
-
return request
|
|
66
|
-
}
|
|
54
|
+
export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> =>
|
|
55
|
+
aiCallDetailLoader.ensure(spanId)
|
|
67
56
|
|
|
68
57
|
export const invalidateAiCallDetailCache = () => {
|
|
69
|
-
|
|
70
|
-
aiCallDetailInflight.clear()
|
|
58
|
+
aiCallDetailLoader.invalidate()
|
|
71
59
|
}
|
|
@@ -6,7 +6,8 @@ import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from
|
|
|
6
6
|
import { ServiceLogsView } from "../ServiceLogs.tsx"
|
|
7
7
|
import { SpanContentView } from "../SpanContentView.tsx"
|
|
8
8
|
import { SpanDetailPane } from "../SpanDetailPane.tsx"
|
|
9
|
-
import type {
|
|
9
|
+
import type { DetailView, LogState, ServiceLogState, TraceDetailState } from "../atoms.ts"
|
|
10
|
+
import type { AiCallDetailState } from "../aiState.ts"
|
|
10
11
|
import { colors, SEPARATOR } from "../theme.ts"
|
|
11
12
|
import { TraceDetailsPane } from "../TraceDetailsPane.tsx"
|
|
12
13
|
import type { TraceListProps } from "../TraceList.tsx"
|
|
@@ -5,34 +5,17 @@ import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
|
|
|
5
5
|
import {
|
|
6
6
|
activeAttrKeyAtom,
|
|
7
7
|
activeAttrValueAtom,
|
|
8
|
-
aiCallDetailStateAtom,
|
|
9
8
|
autoRefreshAtom,
|
|
10
|
-
chatDetailChunkIdAtom,
|
|
11
|
-
chatDetailScrollOffsetAtom,
|
|
12
9
|
collapsedSpanIdsAtom,
|
|
13
10
|
detailViewAtom,
|
|
14
|
-
ensureAiCallDetail,
|
|
15
|
-
ensureTraceAttributeKeys,
|
|
16
11
|
filterModeAtom,
|
|
17
12
|
filterTextAtom,
|
|
18
|
-
getCachedAiCallDetail,
|
|
19
|
-
initialAiCallDetailState,
|
|
20
13
|
initialLogState,
|
|
21
14
|
initialServiceLogState,
|
|
22
15
|
initialTraceDetailState,
|
|
23
|
-
invalidateAiCallDetailCache,
|
|
24
|
-
invalidateFacetCaches,
|
|
25
|
-
loadFilteredTraceSummaries,
|
|
26
|
-
loadRecentTraceSummaries,
|
|
27
|
-
loadServiceLogs,
|
|
28
|
-
loadTraceDetail,
|
|
29
|
-
loadTraceLogs,
|
|
30
|
-
loadTraceServices,
|
|
31
16
|
logStateAtom,
|
|
32
|
-
persistSelectedService,
|
|
33
17
|
refreshNonceAtom,
|
|
34
18
|
selectedAttrIndexAtom,
|
|
35
|
-
selectedChatChunkIdAtom,
|
|
36
19
|
selectedServiceLogIndexAtom,
|
|
37
20
|
selectedSpanIndexAtom,
|
|
38
21
|
selectedTraceIndexAtom,
|
|
@@ -43,7 +26,28 @@ import {
|
|
|
43
26
|
type TraceSortMode,
|
|
44
27
|
traceSortAtom,
|
|
45
28
|
traceStateAtom,
|
|
46
|
-
} from "../
|
|
29
|
+
} from "../atoms.ts"
|
|
30
|
+
import {
|
|
31
|
+
aiCallDetailStateAtom,
|
|
32
|
+
chatDetailChunkIdAtom,
|
|
33
|
+
chatDetailScrollOffsetAtom,
|
|
34
|
+
ensureAiCallDetail,
|
|
35
|
+
getCachedAiCallDetail,
|
|
36
|
+
initialAiCallDetailState,
|
|
37
|
+
invalidateAiCallDetailCache,
|
|
38
|
+
selectedChatChunkIdAtom,
|
|
39
|
+
} from "../aiState.ts"
|
|
40
|
+
import {
|
|
41
|
+
ensureTraceAttributeKeys,
|
|
42
|
+
ensureTraceAttributeValues,
|
|
43
|
+
loadFilteredTraceSummaries,
|
|
44
|
+
loadRecentTraceSummaries,
|
|
45
|
+
loadServiceLogs,
|
|
46
|
+
loadTraceDetail,
|
|
47
|
+
loadTraceLogs,
|
|
48
|
+
loadTraceServices,
|
|
49
|
+
} from "../loaders.ts"
|
|
50
|
+
import { persistSelectedService } from "../persistence.ts"
|
|
47
51
|
import { isAiSpan } from "../../domain.ts"
|
|
48
52
|
import { buildChunks, type Chunk } from "../aiChatModel.ts"
|
|
49
53
|
import { parseFilterText } from "../filterParser.ts"
|
|
@@ -166,17 +170,25 @@ export const useTraceScreenData = () => {
|
|
|
166
170
|
serviceLogCacheRef.current.clear()
|
|
167
171
|
traceDetailInflightRef.current.clear()
|
|
168
172
|
traceLogInflightRef.current.clear()
|
|
169
|
-
invalidateFacetCaches()
|
|
170
173
|
invalidateAiCallDetailCache()
|
|
171
174
|
}, [refreshNonce])
|
|
172
175
|
|
|
173
176
|
// Pre-warm the attribute picker facet keys for the currently-selected
|
|
174
|
-
// service so pressing `f` feels instant.
|
|
175
|
-
//
|
|
177
|
+
// service so pressing `f` feels instant. Once keys are known, also
|
|
178
|
+
// prefetch the value lists for the first few visible keys so the common
|
|
179
|
+
// path of opening `f`, picking a top key, and reopening again stays
|
|
180
|
+
// near-instant. Fire-and-forget; errors are surfaced when the user
|
|
181
|
+
// actually opens the picker.
|
|
176
182
|
useEffect(() => {
|
|
177
183
|
if (!selectedTraceService) return
|
|
178
|
-
void ensureTraceAttributeKeys(selectedTraceService)
|
|
179
|
-
|
|
184
|
+
void ensureTraceAttributeKeys(selectedTraceService)
|
|
185
|
+
.then((entry) => Promise.allSettled(
|
|
186
|
+
entry.data
|
|
187
|
+
.slice(0, 6)
|
|
188
|
+
.map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
|
|
189
|
+
))
|
|
190
|
+
.catch(() => {})
|
|
191
|
+
}, [selectedTraceService])
|
|
180
192
|
|
|
181
193
|
useEffect(() => {
|
|
182
194
|
let cancelled = false
|
package/src/ui/atoms.ts
CHANGED
|
@@ -87,7 +87,7 @@ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.ke
|
|
|
87
87
|
export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
88
88
|
export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
|
|
89
89
|
export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
90
|
-
export const autoRefreshAtom = Atom.make(
|
|
90
|
+
export const autoRefreshAtom = Atom.make(true).pipe(Atom.keepAlive)
|
|
91
91
|
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
92
92
|
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
93
93
|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
3
|
+
|
|
4
|
+
describe("makeCachedLoader", () => {
|
|
5
|
+
it("ensures cached values without loading twice", async () => {
|
|
6
|
+
let loads = 0
|
|
7
|
+
const loader = makeCachedLoader<string, number>({ load: async () => ++loads })
|
|
8
|
+
|
|
9
|
+
expect(await loader.ensure("key")).toBe(1)
|
|
10
|
+
expect(await loader.ensure("key")).toBe(1)
|
|
11
|
+
expect(loads).toBe(1)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("refreshes stale values while publishing the updated cache", async () => {
|
|
15
|
+
let value = 1
|
|
16
|
+
const loader = makeCachedLoader<string, number>({ load: async () => value })
|
|
17
|
+
|
|
18
|
+
expect(await loader.ensure("key")).toBe(1)
|
|
19
|
+
value = 2
|
|
20
|
+
expect(await loader.refresh("key")).toBe(2)
|
|
21
|
+
expect(loader.get("key")).toBe(2)
|
|
22
|
+
})
|
|
23
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// Generic cache with inflight-promise dedup, used by the facet and AI
|
|
2
|
+
// call detail caches. Both previously duplicated this pattern by hand:
|
|
3
|
+
// a Map for the cached value, a parallel Map for in-flight promises,
|
|
4
|
+
// and three small exports (get / ensure / invalidate). This factory is
|
|
5
|
+
// the seam; callers only need to supply a loader and (optionally) a
|
|
6
|
+
// hash function for compound keys.
|
|
7
|
+
|
|
8
|
+
export interface CachedLoader<K, V> {
|
|
9
|
+
/** Synchronous cache lookup. Returns `undefined` if the key has never been loaded. */
|
|
10
|
+
readonly get: (key: K) => V | undefined
|
|
11
|
+
/** Resolves with the cached value if present, otherwise loads it (deduplicating concurrent calls for the same key). */
|
|
12
|
+
readonly ensure: (key: K) => Promise<V>
|
|
13
|
+
/** Loads the latest value while deduplicating any load already running for this key. */
|
|
14
|
+
readonly refresh: (key: K) => Promise<V>
|
|
15
|
+
/** Drops every cached value and aborts dedup tracking for in-flight loads. */
|
|
16
|
+
readonly invalidate: () => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface CachedLoaderOptions<K, V> {
|
|
20
|
+
readonly load: (key: K) => Promise<V>
|
|
21
|
+
/** Required when `K` is a compound type that can't be used as a Map key directly. */
|
|
22
|
+
readonly hash?: (key: K) => string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const makeCachedLoader = <K, V>(opts: CachedLoaderOptions<K, V>): CachedLoader<K, V> => {
|
|
26
|
+
const hash = opts.hash ?? ((k) => k as unknown as string)
|
|
27
|
+
const cache = new Map<string, V>()
|
|
28
|
+
const inflight = new Map<string, Promise<V>>()
|
|
29
|
+
|
|
30
|
+
const get = (key: K) => cache.get(hash(key))
|
|
31
|
+
|
|
32
|
+
const refresh = (key: K): Promise<V> => {
|
|
33
|
+
const h = hash(key)
|
|
34
|
+
const existing = inflight.get(h)
|
|
35
|
+
if (existing) return existing
|
|
36
|
+
const request = opts.load(key)
|
|
37
|
+
.then((data) => {
|
|
38
|
+
cache.set(h, data)
|
|
39
|
+
return data
|
|
40
|
+
})
|
|
41
|
+
.finally(() => {
|
|
42
|
+
inflight.delete(h)
|
|
43
|
+
})
|
|
44
|
+
inflight.set(h, request)
|
|
45
|
+
return request
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const ensure = (key: K): Promise<V> => {
|
|
49
|
+
const h = hash(key)
|
|
50
|
+
if (cache.has(h)) return Promise.resolve(cache.get(h) as V)
|
|
51
|
+
return refresh(key)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const invalidate = () => {
|
|
55
|
+
cache.clear()
|
|
56
|
+
inflight.clear()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return { get, ensure, refresh, invalidate }
|
|
60
|
+
}
|
package/src/ui/loaders.ts
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import { Effect } from "effect"
|
|
2
2
|
import { config } from "../config.ts"
|
|
3
3
|
import { queryRuntime } from "../runtime.ts"
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
4
|
+
import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
|
|
5
|
+
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
6
6
|
|
|
7
7
|
export const loadTraceServices = () =>
|
|
8
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
8
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
|
|
9
9
|
|
|
10
10
|
export const loadRecentTraceSummaries = (serviceName: string) =>
|
|
11
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
11
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceSummaries(serviceName)))
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
14
|
* Server-side trace summary search. Accepts any combination of:
|
|
@@ -29,7 +29,7 @@ export const loadFilteredTraceSummaries = (
|
|
|
29
29
|
readonly aiText?: string | null
|
|
30
30
|
},
|
|
31
31
|
) =>
|
|
32
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
32
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.searchTraceSummaries({
|
|
33
33
|
serviceName,
|
|
34
34
|
attributeFilters: options.attributeFilters,
|
|
35
35
|
aiText: options.aiText ?? null,
|
|
@@ -37,10 +37,10 @@ export const loadFilteredTraceSummaries = (
|
|
|
37
37
|
})))
|
|
38
38
|
|
|
39
39
|
export const loadTraceAttributeKeys = (serviceName: string) =>
|
|
40
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
40
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
|
|
41
41
|
|
|
42
42
|
export const loadTraceAttributeValues = (serviceName: string, key: string) =>
|
|
43
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
43
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
|
|
44
44
|
|
|
45
45
|
// ---------------------------------------------------------------------------
|
|
46
46
|
// Facet cache (drives the `f` attribute filter picker)
|
|
@@ -56,65 +56,46 @@ export interface FacetCacheEntry {
|
|
|
56
56
|
readonly fetchedAt: Date
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
-
const
|
|
60
|
-
const facetValuesCache = new Map<string, FacetCacheEntry>()
|
|
61
|
-
const facetKeysInflight = new Map<string, Promise<FacetCacheEntry>>()
|
|
62
|
-
const facetValuesInflight = new Map<string, Promise<FacetCacheEntry>>()
|
|
59
|
+
const wrapWithTimestamp = (data: readonly FacetRow[]): FacetCacheEntry => ({ data, fetchedAt: new Date() })
|
|
63
60
|
|
|
64
|
-
const
|
|
61
|
+
const facetKeysLoader = makeCachedLoader<string, FacetCacheEntry>({
|
|
62
|
+
load: (service) => loadTraceAttributeKeys(service).then(wrapWithTimestamp),
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const facetValuesLoader = makeCachedLoader<{ readonly service: string; readonly key: string }, FacetCacheEntry>({
|
|
66
|
+
hash: ({ service, key }) => `${service}\u0000${key}`,
|
|
67
|
+
load: ({ service, key }) => loadTraceAttributeValues(service, key).then(wrapWithTimestamp),
|
|
68
|
+
})
|
|
65
69
|
|
|
66
70
|
export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
|
|
67
|
-
|
|
71
|
+
facetKeysLoader.get(service) ?? null
|
|
68
72
|
|
|
69
73
|
export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
|
|
70
|
-
|
|
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
|
-
}
|
|
74
|
+
facetValuesLoader.get({ service, key }) ?? null
|
|
87
75
|
|
|
88
|
-
export const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
facetValuesInflight.delete(cacheKey)
|
|
100
|
-
})
|
|
101
|
-
facetValuesInflight.set(cacheKey, request)
|
|
102
|
-
return request
|
|
103
|
-
}
|
|
76
|
+
export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> =>
|
|
77
|
+
facetKeysLoader.ensure(service)
|
|
78
|
+
|
|
79
|
+
export const refreshTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> =>
|
|
80
|
+
facetKeysLoader.refresh(service)
|
|
81
|
+
|
|
82
|
+
export const ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> =>
|
|
83
|
+
facetValuesLoader.ensure({ service, key })
|
|
84
|
+
|
|
85
|
+
export const refreshTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> =>
|
|
86
|
+
facetValuesLoader.refresh({ service, key })
|
|
104
87
|
|
|
105
88
|
/** Called from the refreshNonce effect alongside the trace / log cache clears. */
|
|
106
89
|
export const invalidateFacetCaches = () => {
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
facetKeysInflight.clear()
|
|
110
|
-
facetValuesInflight.clear()
|
|
90
|
+
facetKeysLoader.invalidate()
|
|
91
|
+
facetValuesLoader.invalidate()
|
|
111
92
|
}
|
|
112
93
|
|
|
113
94
|
export const loadTraceDetail = (traceId: string) =>
|
|
114
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
95
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
|
|
115
96
|
|
|
116
97
|
export const loadTraceLogs = (traceId: string) =>
|
|
117
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
98
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
|
|
118
99
|
|
|
119
100
|
export const loadServiceLogs = (serviceName: string) =>
|
|
120
|
-
queryRuntime.runPromise(Effect.flatMap(
|
|
101
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))
|
package/src/ui/persistence.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from "node:fs"
|
|
2
2
|
import { dirname } from "node:path"
|
|
3
3
|
import { config } from "../config.ts"
|
|
4
|
-
import type
|
|
4
|
+
import { defaultThemeName, type ThemeName } from "./theme.ts"
|
|
5
5
|
|
|
6
6
|
const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
|
|
7
7
|
|
|
@@ -26,9 +26,9 @@ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
|
26
26
|
export const readLastTheme = (): ThemeName => {
|
|
27
27
|
try {
|
|
28
28
|
const raw = readFileSync(lastThemePath, "utf-8").trim()
|
|
29
|
-
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw :
|
|
29
|
+
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : defaultThemeName
|
|
30
30
|
} catch {
|
|
31
|
-
return
|
|
31
|
+
return defaultThemeName
|
|
32
32
|
}
|
|
33
33
|
}
|
|
34
34
|
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
|
|
|
2
2
|
import { Children } from "react"
|
|
3
3
|
import { colors } from "./theme.ts"
|
|
4
4
|
import { fitCell, truncateText } from "./format.ts"
|
|
5
|
-
import type { DetailView } from "./
|
|
5
|
+
import type { DetailView } from "./atoms.ts"
|
|
6
6
|
|
|
7
7
|
export const BlankRow = () => <box height={1} />
|
|
8
8
|
|
package/src/ui/state.ts
CHANGED
package/src/ui/theme.ts
CHANGED
|
@@ -135,13 +135,15 @@ export const themes = {
|
|
|
135
135
|
|
|
136
136
|
export type ThemeName = keyof typeof themes
|
|
137
137
|
|
|
138
|
-
export const
|
|
138
|
+
export const defaultThemeName: ThemeName = "tokyo-night"
|
|
139
139
|
|
|
140
|
-
export const
|
|
141
|
-
|
|
140
|
+
export const themeOrder: readonly ThemeName[] = ["tokyo-night", "catppuccin", "motel-default"]
|
|
141
|
+
|
|
142
|
+
export const colors: ThemeColors = { ...themes[defaultThemeName].colors }
|
|
143
|
+
export const waterfallColors: ThemeWaterfallColors = { ...themes[defaultThemeName].waterfall }
|
|
142
144
|
|
|
143
145
|
export const applyTheme = (name: ThemeName) => {
|
|
144
|
-
const theme = themes[name] ??
|
|
146
|
+
const theme = themes[name] ?? themes[defaultThemeName]
|
|
145
147
|
Object.assign(colors, theme.colors)
|
|
146
148
|
Object.assign(waterfallColors, theme.waterfall)
|
|
147
149
|
return theme
|
|
@@ -152,7 +154,7 @@ export const cycleThemeName = (current: ThemeName) => {
|
|
|
152
154
|
return themeOrder[nextIndex] ?? themeOrder[0]
|
|
153
155
|
}
|
|
154
156
|
|
|
155
|
-
export const themeLabel = (name: ThemeName) => themes[name]?.label ??
|
|
157
|
+
export const themeLabel = (name: ThemeName) => themes[name]?.label ?? themes[defaultThemeName].label
|
|
156
158
|
|
|
157
159
|
export const SEPARATOR = " \u00b7 "
|
|
158
160
|
export const G_PREFIX_TIMEOUT_MS = 500
|
|
@@ -52,6 +52,8 @@ const rootWaterfallRow = (snap: string) =>
|
|
|
52
52
|
describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
53
53
|
const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
|
|
54
54
|
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
55
|
+
const port = 45_000 + Math.floor(Math.random() * 5_000)
|
|
56
|
+
const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
|
|
55
57
|
const lastServicePath = join(tempDir, "last-service.txt")
|
|
56
58
|
let canRun = false
|
|
57
59
|
|
|
@@ -66,6 +68,7 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
|
66
68
|
cwd: process.cwd(),
|
|
67
69
|
env: {
|
|
68
70
|
...process.env,
|
|
71
|
+
...daemonEnv,
|
|
69
72
|
MOTEL_OTEL_DB_PATH: dbPath,
|
|
70
73
|
MOTEL_OTEL_ENABLED: "false",
|
|
71
74
|
},
|
|
@@ -87,6 +90,10 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
|
87
90
|
"--rows", "40",
|
|
88
91
|
"--cwd", process.cwd(),
|
|
89
92
|
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
93
|
+
"--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
|
|
94
|
+
"--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
|
|
95
|
+
"--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
|
|
96
|
+
"--env", `MOTEL_OTEL_PORT=${port}`,
|
|
90
97
|
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
91
98
|
"--timeout", "15000",
|
|
92
99
|
])
|
|
@@ -96,7 +103,11 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
|
96
103
|
}, 60_000)
|
|
97
104
|
|
|
98
105
|
afterAll(async () => {
|
|
99
|
-
if (canRun)
|
|
106
|
+
if (canRun) {
|
|
107
|
+
await tui(["close", "--session", SESSION])
|
|
108
|
+
const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
|
|
109
|
+
await stop.exited
|
|
110
|
+
}
|
|
100
111
|
try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
101
112
|
})
|
|
102
113
|
|