@kitlangton/motel 0.2.0 → 0.2.1
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 +5 -3
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +111 -121
- 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 +52 -0
- package/src/services/TelemetryStore.ts +151 -26
- 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 +243 -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 +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +2 -1
- package/src/ui/Waterfall.tsx +38 -138
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +291 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +173 -39
- 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
package/AGENTS.md
CHANGED
|
@@ -28,6 +28,11 @@
|
|
|
28
28
|
- Build the web UI: `bun run web:build`
|
|
29
29
|
- Dev the web UI (with hot reload): `bun run web:dev`
|
|
30
30
|
- Typecheck: `bun run typecheck`
|
|
31
|
+
- Effect LSP diagnostics over the whole project: `bunx effect-language-service diagnostics --project tsconfig.json --format text`
|
|
32
|
+
- Effect LSP interactive setup wizard: `bunx effect-language-service setup`
|
|
33
|
+
|
|
34
|
+
## Effect LSP
|
|
35
|
+
The repo is wired up with `@effect/language-service` as a `tsconfig.json` `plugins` entry. Editors that pick up the TypeScript workspace plugin (Zed, VSCode, Cursor, NVim via vtsls) will surface Effect-specific diagnostics, quick fixes, and refactors inline. In Zed this requires selecting the workspace TypeScript version — it does so automatically when `node_modules/typescript` is present.
|
|
31
36
|
|
|
32
37
|
## Verification
|
|
33
38
|
- The built-in verification step is `bun run typecheck`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitlangton/motel",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "A local OpenTelemetry ingest + TUI viewer for development, backed by SQLite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
},
|
|
46
46
|
"scripts": {
|
|
47
47
|
"clear-motel-debug": "bun run skills/motel-debug/scripts/clear-motel-debug.ts",
|
|
48
|
-
"dev": "bun run src/motel.ts tui",
|
|
48
|
+
"dev": "MOTEL_OTEL_ENABLED=true bun run src/motel.ts tui",
|
|
49
49
|
"start": "bun run src/motel.ts tui",
|
|
50
50
|
"daemon": "bun run src/motel.ts daemon",
|
|
51
51
|
"status": "bun run src/motel.ts status",
|
|
@@ -67,10 +67,12 @@
|
|
|
67
67
|
"log-stats": "bun run src/cli.ts log-stats",
|
|
68
68
|
"web:dev": "cd web && npx vite",
|
|
69
69
|
"web:build": "cd web && npx vite build",
|
|
70
|
+
"story:chat": "bun run src/storybook/aiChatStory.tsx",
|
|
70
71
|
"typecheck": "tsc --noEmit",
|
|
71
72
|
"prepublishOnly": "bun run web:build"
|
|
72
73
|
},
|
|
73
74
|
"devDependencies": {
|
|
75
|
+
"@effect/language-service": "^0.85.1",
|
|
74
76
|
"@types/bun": "^1.3.12",
|
|
75
77
|
"@types/react": "^19.2.14",
|
|
76
78
|
"typescript": "^6.0.2"
|
|
@@ -78,7 +80,7 @@
|
|
|
78
80
|
"dependencies": {
|
|
79
81
|
"@effect/atom-react": "^4.0.0-beta.49",
|
|
80
82
|
"@effect/opentelemetry": "^4.0.0-beta.49",
|
|
81
|
-
"@effect/platform-bun": "^4.0.0-beta.
|
|
83
|
+
"@effect/platform-bun": "^4.0.0-beta.50",
|
|
82
84
|
"@opentelemetry/api": "^1.9.0",
|
|
83
85
|
"@opentelemetry/exporter-logs-otlp-http": "^0.214.0",
|
|
84
86
|
"@opentelemetry/exporter-trace-otlp-http": "^0.211.0",
|
package/src/App.tsx
CHANGED
|
@@ -8,21 +8,173 @@ import { useAppLayout } from "./ui/app/useAppLayout.ts"
|
|
|
8
8
|
import { useTraceScreenData } from "./ui/app/useTraceScreenData.ts"
|
|
9
9
|
import { TraceWorkspace } from "./ui/app/TraceWorkspace.tsx"
|
|
10
10
|
import {
|
|
11
|
+
type AttrFacetState,
|
|
11
12
|
attrPickerIndexAtom,
|
|
12
13
|
attrPickerInputAtom,
|
|
13
14
|
attrPickerModeAtom,
|
|
14
15
|
attrFacetStateAtom,
|
|
16
|
+
chatDetailChunkIdAtom,
|
|
17
|
+
chatDetailScrollOffsetAtom,
|
|
15
18
|
noticeAtom,
|
|
16
19
|
persistSelectedTheme,
|
|
20
|
+
selectedAttrIndexAtom,
|
|
21
|
+
selectedChatChunkIdAtom,
|
|
17
22
|
selectedThemeAtom,
|
|
18
23
|
waterfallFilterModeAtom,
|
|
19
24
|
waterfallFilterTextAtom,
|
|
20
25
|
} from "./ui/state.ts"
|
|
26
|
+
import type { ThemeName } from "./ui/theme.ts"
|
|
21
27
|
import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
|
|
22
|
-
import { getVisibleSpans } from "./ui/Waterfall.tsx"
|
|
23
28
|
import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
|
|
24
29
|
import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
|
|
25
30
|
import { useAttrFilterPicker } from "./ui/useAttrFilterPicker.ts"
|
|
31
|
+
import { getVisibleSpans } from "./ui/Waterfall.tsx"
|
|
32
|
+
|
|
33
|
+
const NOTICE_TIMEOUT_MS = 2500
|
|
34
|
+
|
|
35
|
+
const buildHeaderModel = ({
|
|
36
|
+
headerFooterWidth,
|
|
37
|
+
selectedTraceService,
|
|
38
|
+
activeAttrKey,
|
|
39
|
+
activeAttrValue,
|
|
40
|
+
autoRefresh,
|
|
41
|
+
fetchedAt,
|
|
42
|
+
status,
|
|
43
|
+
}: {
|
|
44
|
+
readonly headerFooterWidth: number
|
|
45
|
+
readonly selectedTraceService: string | null
|
|
46
|
+
readonly activeAttrKey: string | null
|
|
47
|
+
readonly activeAttrValue: string | null
|
|
48
|
+
readonly autoRefresh: boolean
|
|
49
|
+
readonly fetchedAt: Date | null
|
|
50
|
+
readonly status: string
|
|
51
|
+
}) => {
|
|
52
|
+
const serviceLabel = selectedTraceService ?? "none"
|
|
53
|
+
const autoLabel = autoRefresh ? "● live" : "○ paused"
|
|
54
|
+
const attrFilterLabel = activeAttrKey && activeAttrValue
|
|
55
|
+
? ` [${activeAttrKey}=${activeAttrValue.length > 20 ? `${activeAttrValue.slice(0, 19)}…` : activeAttrValue}]`
|
|
56
|
+
: ""
|
|
57
|
+
const right = fetchedAt
|
|
58
|
+
? `${autoLabel} ${formatTimestamp(fetchedAt)}`
|
|
59
|
+
: status === "loading"
|
|
60
|
+
? "loading traces..."
|
|
61
|
+
: ""
|
|
62
|
+
const leftLength = "MOTEL".length + SEPARATOR.length + serviceLabel.length + attrFilterLabel.length
|
|
63
|
+
const gap = Math.max(2, headerFooterWidth - leftLength - right.length)
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
serviceLabel,
|
|
67
|
+
attrFilterLabel,
|
|
68
|
+
right,
|
|
69
|
+
gap,
|
|
70
|
+
} as const
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const AppHeader = ({
|
|
74
|
+
serviceLabel,
|
|
75
|
+
attrFilterLabel,
|
|
76
|
+
gap,
|
|
77
|
+
right,
|
|
78
|
+
}: {
|
|
79
|
+
readonly serviceLabel: string
|
|
80
|
+
readonly attrFilterLabel: string
|
|
81
|
+
readonly gap: number
|
|
82
|
+
readonly right: string
|
|
83
|
+
}) => (
|
|
84
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="column">
|
|
85
|
+
<TextLine>
|
|
86
|
+
<span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
87
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
88
|
+
<span fg={colors.muted}>{serviceLabel}</span>
|
|
89
|
+
{attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
|
|
90
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
91
|
+
<span fg={colors.muted} attributes={TextAttributes.BOLD}>{right}</span>
|
|
92
|
+
</TextLine>
|
|
93
|
+
</box>
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
const AppFooter = ({
|
|
97
|
+
showSplit,
|
|
98
|
+
footerHeight,
|
|
99
|
+
contentWidth,
|
|
100
|
+
leftPaneWidth,
|
|
101
|
+
rightPaneWidth,
|
|
102
|
+
footerNotice,
|
|
103
|
+
spanNavActive,
|
|
104
|
+
detailView,
|
|
105
|
+
autoRefresh,
|
|
106
|
+
headerFooterWidth,
|
|
107
|
+
}: {
|
|
108
|
+
readonly showSplit: boolean
|
|
109
|
+
readonly footerHeight: number
|
|
110
|
+
readonly contentWidth: number
|
|
111
|
+
readonly leftPaneWidth: number
|
|
112
|
+
readonly rightPaneWidth: number
|
|
113
|
+
readonly footerNotice: string | null
|
|
114
|
+
readonly spanNavActive: boolean
|
|
115
|
+
readonly detailView: "waterfall" | "span-detail" | "service-logs"
|
|
116
|
+
readonly autoRefresh: boolean
|
|
117
|
+
readonly headerFooterWidth: number
|
|
118
|
+
}) => {
|
|
119
|
+
if (footerHeight <= 0) return null
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<>
|
|
123
|
+
{showSplit
|
|
124
|
+
? <SplitDivider leftWidth={leftPaneWidth} junction={"┴"} rightWidth={rightPaneWidth} />
|
|
125
|
+
: <Divider width={contentWidth} />}
|
|
126
|
+
<box paddingLeft={1} paddingRight={1} flexDirection="column" height={footerHeight}>
|
|
127
|
+
{footerNotice ? (
|
|
128
|
+
<PlainLine text={footerNotice} fg={colors.count} />
|
|
129
|
+
) : (
|
|
130
|
+
<FooterHints spanNavActive={spanNavActive} detailView={detailView} autoRefresh={autoRefresh} width={headerFooterWidth} />
|
|
131
|
+
)}
|
|
132
|
+
</box>
|
|
133
|
+
</>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const AppOverlays = ({
|
|
138
|
+
width,
|
|
139
|
+
height,
|
|
140
|
+
showHelp,
|
|
141
|
+
autoRefresh,
|
|
142
|
+
selectedTheme,
|
|
143
|
+
setShowHelp,
|
|
144
|
+
pickerMode,
|
|
145
|
+
pickerInput,
|
|
146
|
+
pickerIndex,
|
|
147
|
+
activeAttrKey,
|
|
148
|
+
attrFacets,
|
|
149
|
+
}: {
|
|
150
|
+
readonly width: number
|
|
151
|
+
readonly height: number
|
|
152
|
+
readonly showHelp: boolean
|
|
153
|
+
readonly autoRefresh: boolean
|
|
154
|
+
readonly selectedTheme: ThemeName
|
|
155
|
+
readonly setShowHelp: (value: boolean) => void
|
|
156
|
+
readonly pickerMode: "off" | "keys" | "values"
|
|
157
|
+
readonly pickerInput: string
|
|
158
|
+
readonly pickerIndex: number
|
|
159
|
+
readonly activeAttrKey: string | null
|
|
160
|
+
readonly attrFacets: AttrFacetState
|
|
161
|
+
}) => (
|
|
162
|
+
<>
|
|
163
|
+
{showHelp ? <HelpModal width={width} height={height} autoRefresh={autoRefresh} themeLabel={themeLabel(selectedTheme)} onClose={() => setShowHelp(false)} /> : null}
|
|
164
|
+
{pickerMode !== "off" ? (
|
|
165
|
+
<AttrFilterModal
|
|
166
|
+
width={width}
|
|
167
|
+
height={height}
|
|
168
|
+
mode={pickerMode}
|
|
169
|
+
input={pickerInput}
|
|
170
|
+
selectedIndex={pickerIndex}
|
|
171
|
+
selectedKey={activeAttrKey}
|
|
172
|
+
state={attrFacets}
|
|
173
|
+
onClose={() => { /* handled via keyboard */ }}
|
|
174
|
+
/>
|
|
175
|
+
) : null}
|
|
176
|
+
</>
|
|
177
|
+
)
|
|
26
178
|
|
|
27
179
|
export const App = () => {
|
|
28
180
|
const { width, height } = useTerminalDimensions()
|
|
@@ -54,6 +206,8 @@ export const App = () => {
|
|
|
54
206
|
selectedTraceSummary,
|
|
55
207
|
selectedTrace,
|
|
56
208
|
filteredTraces,
|
|
209
|
+
aiCallDetailState,
|
|
210
|
+
aiChatChunks,
|
|
57
211
|
} = useTraceScreenData()
|
|
58
212
|
const [pickerMode] = useAtom(attrPickerModeAtom)
|
|
59
213
|
const [pickerInput] = useAtom(attrPickerInputAtom)
|
|
@@ -61,6 +215,10 @@ export const App = () => {
|
|
|
61
215
|
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
62
216
|
const [waterfallFilterMode] = useAtom(waterfallFilterModeAtom)
|
|
63
217
|
const [waterfallFilterText] = useAtom(waterfallFilterTextAtom)
|
|
218
|
+
const [selectedAttrIndex] = useAtom(selectedAttrIndexAtom)
|
|
219
|
+
const [selectedChatChunkId, setSelectedChatChunkId] = useAtom(selectedChatChunkIdAtom)
|
|
220
|
+
const [chatDetailChunkId, setChatDetailChunkId] = useAtom(chatDetailChunkIdAtom)
|
|
221
|
+
const [chatDetailScrollOffset, setChatDetailScrollOffset] = useAtom(chatDetailScrollOffsetAtom)
|
|
64
222
|
useAttrFilterPicker(activeAttrKey)
|
|
65
223
|
|
|
66
224
|
const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
|
|
@@ -90,7 +248,7 @@ export const App = () => {
|
|
|
90
248
|
setNotice(message)
|
|
91
249
|
noticeTimeoutRef.current = globalThis.setTimeout(() => {
|
|
92
250
|
setNotice((current) => (current === message ? null : current))
|
|
93
|
-
},
|
|
251
|
+
}, NOTICE_TIMEOUT_MS)
|
|
94
252
|
}
|
|
95
253
|
|
|
96
254
|
useEffect(() => () => {
|
|
@@ -106,6 +264,7 @@ export const App = () => {
|
|
|
106
264
|
const { spanNavActive } = useKeyboardNav({
|
|
107
265
|
selectedTrace,
|
|
108
266
|
filteredTraces,
|
|
267
|
+
aiChatChunks,
|
|
109
268
|
isWideLayout,
|
|
110
269
|
wideBodyLines,
|
|
111
270
|
narrowBodyLines,
|
|
@@ -114,30 +273,41 @@ export const App = () => {
|
|
|
114
273
|
flashNotice,
|
|
115
274
|
})
|
|
116
275
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
: traceState.status
|
|
125
|
-
|
|
126
|
-
: ""
|
|
127
|
-
const headerLeftLen = "MOTEL".length + SEPARATOR.length + headerServiceLabel.length + attrFilterLabel.length
|
|
128
|
-
const headerGap = Math.max(2, headerFooterWidth - headerLeftLen - headerRight.length)
|
|
129
|
-
const visibleFooterNotice = footerNotice
|
|
276
|
+
const headerModel = buildHeaderModel({
|
|
277
|
+
headerFooterWidth,
|
|
278
|
+
selectedTraceService,
|
|
279
|
+
activeAttrKey,
|
|
280
|
+
activeAttrValue,
|
|
281
|
+
autoRefresh,
|
|
282
|
+
fetchedAt: traceState.fetchedAt,
|
|
283
|
+
status: traceState.status,
|
|
284
|
+
})
|
|
130
285
|
|
|
131
286
|
const selectTraceById = useCallback((traceId: string) => {
|
|
132
287
|
const index = traceState.data.findIndex((trace) => trace.traceId === traceId)
|
|
133
288
|
if (index >= 0) setSelectedTraceIndex(index)
|
|
134
289
|
}, [setSelectedTraceIndex, traceState.data])
|
|
135
290
|
|
|
291
|
+
const visibleSpans = useMemo(
|
|
292
|
+
() => selectedTrace ? getVisibleSpans(selectedTrace.spans, collapsedSpanIds) : [],
|
|
293
|
+
[selectedTrace, collapsedSpanIds],
|
|
294
|
+
)
|
|
295
|
+
|
|
296
|
+
const openChatChunkDetail = useCallback((chunkId: string) => {
|
|
297
|
+
setSelectedChatChunkId(chunkId)
|
|
298
|
+
setChatDetailChunkId(chunkId)
|
|
299
|
+
setChatDetailScrollOffset(0)
|
|
300
|
+
}, [setSelectedChatChunkId, setChatDetailChunkId, setChatDetailScrollOffset])
|
|
301
|
+
|
|
302
|
+
const closeChatChunkDetail = useCallback(() => {
|
|
303
|
+
setChatDetailChunkId(null)
|
|
304
|
+
setChatDetailScrollOffset(0)
|
|
305
|
+
}, [setChatDetailChunkId, setChatDetailScrollOffset])
|
|
306
|
+
|
|
136
307
|
const selectSpan = useCallback((index: number) => {
|
|
137
|
-
if (
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
}, [collapsedSpanIds, selectedTrace, setSelectedSpanIndex])
|
|
308
|
+
if (visibleSpans.length === 0) return
|
|
309
|
+
setSelectedSpanIndex(Math.max(0, Math.min(index, visibleSpans.length - 1)))
|
|
310
|
+
}, [setSelectedSpanIndex, visibleSpans])
|
|
141
311
|
|
|
142
312
|
const traceListProps = useMemo(() => ({
|
|
143
313
|
traces: filteredTraces,
|
|
@@ -154,27 +324,23 @@ export const App = () => {
|
|
|
154
324
|
onSelectTrace: selectTraceById,
|
|
155
325
|
} as const), [filteredTraces, selectedTraceSummary?.traceId, traceState.status, traceState.error, leftContentWidth, traceState.services, selectedTraceService, spanNavActive, filterText, traceSort, traceState.data.length, selectTraceById])
|
|
156
326
|
|
|
157
|
-
const
|
|
158
|
-
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
327
|
+
const selectedSpan = selectedSpanIndex !== null ? visibleSpans[selectedSpanIndex] ?? null : null
|
|
159
328
|
const selectedSpanLogs = useMemo(
|
|
160
329
|
() => selectedSpan ? logState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
161
330
|
[selectedSpan, logState.data],
|
|
162
331
|
)
|
|
163
332
|
|
|
164
|
-
|
|
333
|
+
// Top/bottom frame dividers only render junction glyphs (`┬` / `┴`)
|
|
334
|
+
// when there's actually a vertical SeparatorColumn in the workspace
|
|
335
|
+
// below/above them to meet. Service-logs view is wide but single-pane,
|
|
336
|
+
// so its frame dividers must be plain — otherwise the junction floats
|
|
337
|
+
// above an empty column and leaves a visible stale sliver when
|
|
338
|
+
// toggling tab back and forth with the trace view.
|
|
339
|
+
const showSplit = isWideLayout && detailView !== "service-logs"
|
|
165
340
|
|
|
166
341
|
return (
|
|
167
342
|
<box width={width ?? 100} height={height ?? 24} flexGrow={1} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
168
|
-
<
|
|
169
|
-
<TextLine>
|
|
170
|
-
<span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
171
|
-
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
172
|
-
<span fg={colors.muted}>{headerServiceLabel}</span>
|
|
173
|
-
{attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
|
|
174
|
-
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
175
|
-
<span fg={colors.muted} attributes={TextAttributes.BOLD}>{headerRight}</span>
|
|
176
|
-
</TextLine>
|
|
177
|
-
</box>
|
|
343
|
+
<AppHeader {...headerModel} />
|
|
178
344
|
{showSplit
|
|
179
345
|
? <SplitDivider leftWidth={leftPaneWidth} junction={"┬"} rightWidth={rightPaneWidth} />
|
|
180
346
|
: <Divider width={contentWidth} />}
|
|
@@ -199,35 +365,43 @@ export const App = () => {
|
|
|
199
365
|
viewLevel={viewLevel}
|
|
200
366
|
selectedSpan={selectedSpan}
|
|
201
367
|
selectedSpanLogs={selectedSpanLogs}
|
|
368
|
+
selectedAttrIndex={selectedAttrIndex}
|
|
369
|
+
aiCallDetailState={aiCallDetailState}
|
|
370
|
+
aiChatChunks={aiChatChunks}
|
|
371
|
+
selectedChatChunkId={selectedChatChunkId}
|
|
372
|
+
onSelectChatChunk={setSelectedChatChunkId}
|
|
373
|
+
chatDetailChunkId={chatDetailChunkId}
|
|
374
|
+
onOpenChatChunkDetail={openChatChunkDetail}
|
|
375
|
+
onCloseChatChunkDetail={closeChatChunkDetail}
|
|
376
|
+
chatDetailScrollOffset={chatDetailScrollOffset}
|
|
377
|
+
onSetChatDetailScrollOffset={(updater) => setChatDetailScrollOffset(updater)}
|
|
202
378
|
selectSpan={selectSpan}
|
|
203
379
|
/>
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
/>
|
|
230
|
-
) : null}
|
|
380
|
+
<AppFooter
|
|
381
|
+
showSplit={showSplit}
|
|
382
|
+
footerHeight={footerHeight}
|
|
383
|
+
contentWidth={contentWidth}
|
|
384
|
+
leftPaneWidth={leftPaneWidth}
|
|
385
|
+
rightPaneWidth={rightPaneWidth}
|
|
386
|
+
footerNotice={footerNotice}
|
|
387
|
+
spanNavActive={spanNavActive}
|
|
388
|
+
detailView={detailView}
|
|
389
|
+
autoRefresh={autoRefresh}
|
|
390
|
+
headerFooterWidth={headerFooterWidth}
|
|
391
|
+
/>
|
|
392
|
+
<AppOverlays
|
|
393
|
+
width={width ?? 100}
|
|
394
|
+
height={height ?? 24}
|
|
395
|
+
showHelp={showHelp}
|
|
396
|
+
autoRefresh={autoRefresh}
|
|
397
|
+
selectedTheme={selectedTheme}
|
|
398
|
+
setShowHelp={setShowHelp}
|
|
399
|
+
pickerMode={pickerMode}
|
|
400
|
+
pickerInput={pickerInput}
|
|
401
|
+
pickerIndex={pickerIndex}
|
|
402
|
+
activeAttrKey={activeAttrKey}
|
|
403
|
+
attrFacets={attrFacets}
|
|
404
|
+
/>
|
|
231
405
|
</box>
|
|
232
406
|
)
|
|
233
407
|
}
|
package/src/daemon.test.ts
CHANGED
|
@@ -4,23 +4,70 @@ import * as fs from "node:fs"
|
|
|
4
4
|
import * as os from "node:os"
|
|
5
5
|
import * as path from "node:path"
|
|
6
6
|
import { createDaemonManager } from "./daemon.js"
|
|
7
|
+
import { MOTEL_SERVICE_ID } from "./registry.js"
|
|
7
8
|
|
|
8
9
|
const repoRoot = path.resolve(import.meta.dir, "..")
|
|
9
10
|
|
|
10
11
|
const randomPort = () => 29000 + Math.floor(Math.random() * 2000)
|
|
11
12
|
|
|
12
|
-
|
|
13
|
+
interface Harness {
|
|
14
|
+
readonly runtimeDir: string
|
|
15
|
+
readonly port: number
|
|
16
|
+
readonly databasePath: string
|
|
17
|
+
readonly manager: ReturnType<typeof createDaemonManager>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const makeHarness = (): Harness => {
|
|
13
21
|
const runtimeDir = fs.mkdtempSync(path.join(os.tmpdir(), "motel-daemon-test-"))
|
|
22
|
+
const port = randomPort()
|
|
23
|
+
const databasePath = path.join(runtimeDir, "telemetry.sqlite")
|
|
14
24
|
const manager = createDaemonManager({
|
|
15
25
|
repoRoot,
|
|
16
26
|
runtimeDir,
|
|
17
|
-
databasePath
|
|
18
|
-
port
|
|
27
|
+
databasePath,
|
|
28
|
+
port,
|
|
19
29
|
})
|
|
20
|
-
return {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
30
|
+
return { runtimeDir, port, databasePath, manager }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Start a motel-shaped HTTP server on a test port that answers
|
|
35
|
+
* /api/health with an arbitrary delay. Used to simulate a real daemon
|
|
36
|
+
* that's alive + holding the port but currently slow — the exact
|
|
37
|
+
* scenario that makes `bun dev` fail with EADDRINUSE when the
|
|
38
|
+
* supervisor's health probe times out and it tries to spawn a
|
|
39
|
+
* duplicate. Returns a stop() that releases the port.
|
|
40
|
+
*/
|
|
41
|
+
const startFakeDaemon = (opts: {
|
|
42
|
+
readonly port: number
|
|
43
|
+
readonly databasePath: string
|
|
44
|
+
readonly delayMs: number
|
|
45
|
+
}) => {
|
|
46
|
+
const startedAt = new Date().toISOString()
|
|
47
|
+
const server = Bun.serve({
|
|
48
|
+
port: opts.port,
|
|
49
|
+
hostname: "127.0.0.1",
|
|
50
|
+
async fetch(req) {
|
|
51
|
+
const url = new URL(req.url)
|
|
52
|
+
if (url.pathname !== "/api/health") {
|
|
53
|
+
return new Response("not found", { status: 404 })
|
|
54
|
+
}
|
|
55
|
+
if (opts.delayMs > 0) {
|
|
56
|
+
await new Promise((resolve) => setTimeout(resolve, opts.delayMs))
|
|
57
|
+
}
|
|
58
|
+
return Response.json({
|
|
59
|
+
ok: true,
|
|
60
|
+
service: MOTEL_SERVICE_ID,
|
|
61
|
+
databasePath: opts.databasePath,
|
|
62
|
+
pid: process.pid,
|
|
63
|
+
url: `http://127.0.0.1:${opts.port}`,
|
|
64
|
+
workdir: process.cwd(),
|
|
65
|
+
startedAt,
|
|
66
|
+
version: "0.0.0-test",
|
|
67
|
+
})
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
return { stop: () => server.stop(true) }
|
|
24
71
|
}
|
|
25
72
|
|
|
26
73
|
const activeHarnesses: Array<ReturnType<typeof makeHarness>> = []
|
|
@@ -33,6 +80,96 @@ afterEach(async () => {
|
|
|
33
80
|
})
|
|
34
81
|
|
|
35
82
|
describe("daemon manager", () => {
|
|
83
|
+
test("warm-start via registry is fast even when HTTP health is slow", async () => {
|
|
84
|
+
// The failure mode we're preventing: a fully-healthy motel daemon
|
|
85
|
+
// is alive for our cwd, but its /api/health response queues
|
|
86
|
+
// behind heavy OTLP ingest traffic and takes >1s (seen on this
|
|
87
|
+
// machine: /api/health taking 4s under real load). With an
|
|
88
|
+
// HTTP-only probe the TUI would stall for seconds on every
|
|
89
|
+
// launch; the registry-based fast path should close in <100ms.
|
|
90
|
+
const harness = makeHarness()
|
|
91
|
+
activeHarnesses.push(harness)
|
|
92
|
+
|
|
93
|
+
// Scope the motel registry to the harness's runtime dir so we
|
|
94
|
+
// neither read nor pollute the user's real ~/.local/state/motel.
|
|
95
|
+
const registryRoot = path.join(harness.runtimeDir, "state")
|
|
96
|
+
const originalXdg = process.env.XDG_STATE_HOME
|
|
97
|
+
process.env.XDG_STATE_HOME = registryRoot
|
|
98
|
+
const registryInstancesDir = path.join(registryRoot, "motel", "instances")
|
|
99
|
+
fs.mkdirSync(registryInstancesDir, { recursive: true })
|
|
100
|
+
|
|
101
|
+
// Seed an entry that points at THIS test process. It's alive
|
|
102
|
+
// (we're executing), so isAlive(pid) will report true — the
|
|
103
|
+
// supervisor's fast path will adopt without ever issuing an
|
|
104
|
+
// HTTP request.
|
|
105
|
+
const entryPath = path.join(registryInstancesDir, `${process.pid}.json`)
|
|
106
|
+
fs.writeFileSync(entryPath, JSON.stringify({
|
|
107
|
+
pid: process.pid,
|
|
108
|
+
url: `http://127.0.0.1:${harness.port}`,
|
|
109
|
+
workdir: process.cwd(),
|
|
110
|
+
startedAt: new Date().toISOString(),
|
|
111
|
+
version: "0.0.0-test",
|
|
112
|
+
databasePath: harness.databasePath,
|
|
113
|
+
}), "utf8")
|
|
114
|
+
|
|
115
|
+
// Park a real-but-slow listener on the port. If the supervisor
|
|
116
|
+
// ever falls back to HTTP we'd wait out the 5s delay; a passing
|
|
117
|
+
// test proves the fast path took over.
|
|
118
|
+
const fake = startFakeDaemon({
|
|
119
|
+
port: harness.port,
|
|
120
|
+
databasePath: harness.databasePath,
|
|
121
|
+
delayMs: 5_000,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const start = performance.now()
|
|
126
|
+
const status = await Effect.runPromise(harness.manager.ensure)
|
|
127
|
+
const elapsed = performance.now() - start
|
|
128
|
+
expect(status.running).toBe(true)
|
|
129
|
+
expect(status.managed).toBe(true)
|
|
130
|
+
expect(status.pid).toBe(process.pid)
|
|
131
|
+
// Generous — real-world is <10ms. Primarily guarding against
|
|
132
|
+
// a future regression that silently reintroduces an HTTP probe
|
|
133
|
+
// on the hot path.
|
|
134
|
+
expect(elapsed).toBeLessThan(500)
|
|
135
|
+
} finally {
|
|
136
|
+
fake.stop()
|
|
137
|
+
fs.rmSync(entryPath, { force: true })
|
|
138
|
+
if (originalXdg === undefined) delete process.env.XDG_STATE_HOME
|
|
139
|
+
else process.env.XDG_STATE_HOME = originalXdg
|
|
140
|
+
}
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
test("adopts a slow-to-respond healthy daemon instead of spawning a duplicate", async () => {
|
|
144
|
+
// Reproduces the `bun dev` EADDRINUSE flake. A real daemon is alive
|
|
145
|
+
// and holds the port, but its /api/health response takes longer
|
|
146
|
+
// than the supervisor's 750ms fetch timeout (e.g. the daemon is
|
|
147
|
+
// backfilling FTS or the SQLite writer lock is held). The buggy
|
|
148
|
+
// behaviour: supervisor thinks the port is free, spawns a fresh
|
|
149
|
+
// daemon child, the child tries to bind() → EADDRINUSE → child
|
|
150
|
+
// exits → supervisor throws "exited before becoming healthy".
|
|
151
|
+
//
|
|
152
|
+
// Correct behaviour: supervisor retries the health probe with a
|
|
153
|
+
// longer budget before declaring the port empty, finds the
|
|
154
|
+
// (slow) healthy motel on it, and adopts.
|
|
155
|
+
const harness = makeHarness()
|
|
156
|
+
activeHarnesses.push(harness)
|
|
157
|
+
const fake = startFakeDaemon({
|
|
158
|
+
port: harness.port,
|
|
159
|
+
databasePath: harness.databasePath,
|
|
160
|
+
delayMs: 1_500,
|
|
161
|
+
})
|
|
162
|
+
try {
|
|
163
|
+
const status = await Effect.runPromise(harness.manager.ensure)
|
|
164
|
+
expect(status.running).toBe(true)
|
|
165
|
+
expect(status.managed).toBe(true)
|
|
166
|
+
// PID belongs to the fake test server, not a newly-spawned daemon.
|
|
167
|
+
expect(status.pid).toBe(process.pid)
|
|
168
|
+
} finally {
|
|
169
|
+
fake.stop()
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
36
173
|
test("starts once, reuses the same daemon, and stops cleanly", async () => {
|
|
37
174
|
const harness = makeHarness()
|
|
38
175
|
activeHarnesses.push(harness)
|