@kitlangton/motel 0.1.3 → 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 +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -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 +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -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 +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- 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 +61 -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 +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- 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 +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +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`.
|
|
@@ -146,7 +151,12 @@
|
|
|
146
151
|
- `[` / `]`: switch services
|
|
147
152
|
- `s`: cycle sort mode (recent → slowest → errors)
|
|
148
153
|
- `t`: cycle theme (motel-default → tokyo-night → catppuccin)
|
|
149
|
-
- `/`: enter filter mode
|
|
154
|
+
- `/`: enter filter mode.
|
|
155
|
+
- **In the trace list (L0)** the input matches against the root operation name. Composable modifiers:
|
|
156
|
+
- `:error` — restrict to traces with at least one failed span (client-side)
|
|
157
|
+
- `:ai <query>` — FTS5-backed search against LLM prompt/response/tool content (`AI_FTS_KEYS`) across every span in the trace. Tokens are prefix-matched and implicitly AND'd. Debounced 250ms.
|
|
158
|
+
- Modifiers compose: `/ :ai rate limit :error`
|
|
159
|
+
- **In the waterfall (L1/L2)** the input runs a client-side substring match against each span's operation name and tag values. Non-matching spans are dimmed; the filter bar shows the live match count. `enter` commits (dim persists while you navigate); `esc` clears.
|
|
150
160
|
- `f`: open attribute filter picker (browse span-attribute keys → values for the current service; `backspace` walks back to keys; `esc` in the trace list clears the active filter)
|
|
151
161
|
- `a`: pause or resume auto-refresh
|
|
152
162
|
- `r`: refresh now
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kitlangton/motel",
|
|
3
|
-
"version": "0.1
|
|
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,19 +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,
|
|
23
|
+
waterfallFilterModeAtom,
|
|
24
|
+
waterfallFilterTextAtom,
|
|
18
25
|
} from "./ui/state.ts"
|
|
26
|
+
import type { ThemeName } from "./ui/theme.ts"
|
|
19
27
|
import { applyTheme, colors, SEPARATOR, themeLabel } from "./ui/theme.ts"
|
|
20
|
-
import { getVisibleSpans } from "./ui/Waterfall.tsx"
|
|
21
28
|
import { useKeyboardNav } from "./ui/useKeyboardNav.ts"
|
|
22
29
|
import { AttrFilterModal } from "./ui/AttrFilterModal.tsx"
|
|
23
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
|
+
)
|
|
24
178
|
|
|
25
179
|
export const App = () => {
|
|
26
180
|
const { width, height } = useTerminalDimensions()
|
|
@@ -52,11 +206,19 @@ export const App = () => {
|
|
|
52
206
|
selectedTraceSummary,
|
|
53
207
|
selectedTrace,
|
|
54
208
|
filteredTraces,
|
|
209
|
+
aiCallDetailState,
|
|
210
|
+
aiChatChunks,
|
|
55
211
|
} = useTraceScreenData()
|
|
56
212
|
const [pickerMode] = useAtom(attrPickerModeAtom)
|
|
57
213
|
const [pickerInput] = useAtom(attrPickerInputAtom)
|
|
58
214
|
const [pickerIndex] = useAtom(attrPickerIndexAtom)
|
|
59
215
|
const [attrFacets] = useAtom(attrFacetStateAtom)
|
|
216
|
+
const [waterfallFilterMode] = useAtom(waterfallFilterModeAtom)
|
|
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)
|
|
60
222
|
useAttrFilterPicker(activeAttrKey)
|
|
61
223
|
|
|
62
224
|
const layout = useAppLayout({ width, height, notice, detailView, selectedSpanIndex })
|
|
@@ -86,7 +248,7 @@ export const App = () => {
|
|
|
86
248
|
setNotice(message)
|
|
87
249
|
noticeTimeoutRef.current = globalThis.setTimeout(() => {
|
|
88
250
|
setNotice((current) => (current === message ? null : current))
|
|
89
|
-
},
|
|
251
|
+
}, NOTICE_TIMEOUT_MS)
|
|
90
252
|
}
|
|
91
253
|
|
|
92
254
|
useEffect(() => () => {
|
|
@@ -102,6 +264,7 @@ export const App = () => {
|
|
|
102
264
|
const { spanNavActive } = useKeyboardNav({
|
|
103
265
|
selectedTrace,
|
|
104
266
|
filteredTraces,
|
|
267
|
+
aiChatChunks,
|
|
105
268
|
isWideLayout,
|
|
106
269
|
wideBodyLines,
|
|
107
270
|
narrowBodyLines,
|
|
@@ -110,30 +273,41 @@ export const App = () => {
|
|
|
110
273
|
flashNotice,
|
|
111
274
|
})
|
|
112
275
|
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
: traceState.status
|
|
121
|
-
|
|
122
|
-
: ""
|
|
123
|
-
const headerLeftLen = "MOTEL".length + SEPARATOR.length + headerServiceLabel.length + attrFilterLabel.length
|
|
124
|
-
const headerGap = Math.max(2, headerFooterWidth - headerLeftLen - headerRight.length)
|
|
125
|
-
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
|
+
})
|
|
126
285
|
|
|
127
286
|
const selectTraceById = useCallback((traceId: string) => {
|
|
128
287
|
const index = traceState.data.findIndex((trace) => trace.traceId === traceId)
|
|
129
288
|
if (index >= 0) setSelectedTraceIndex(index)
|
|
130
289
|
}, [setSelectedTraceIndex, traceState.data])
|
|
131
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
|
+
|
|
132
307
|
const selectSpan = useCallback((index: number) => {
|
|
133
|
-
if (
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
}, [collapsedSpanIds, selectedTrace, setSelectedSpanIndex])
|
|
308
|
+
if (visibleSpans.length === 0) return
|
|
309
|
+
setSelectedSpanIndex(Math.max(0, Math.min(index, visibleSpans.length - 1)))
|
|
310
|
+
}, [setSelectedSpanIndex, visibleSpans])
|
|
137
311
|
|
|
138
312
|
const traceListProps = useMemo(() => ({
|
|
139
313
|
traces: filteredTraces,
|
|
@@ -150,27 +324,23 @@ export const App = () => {
|
|
|
150
324
|
onSelectTrace: selectTraceById,
|
|
151
325
|
} as const), [filteredTraces, selectedTraceSummary?.traceId, traceState.status, traceState.error, leftContentWidth, traceState.services, selectedTraceService, spanNavActive, filterText, traceSort, traceState.data.length, selectTraceById])
|
|
152
326
|
|
|
153
|
-
const
|
|
154
|
-
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
327
|
+
const selectedSpan = selectedSpanIndex !== null ? visibleSpans[selectedSpanIndex] ?? null : null
|
|
155
328
|
const selectedSpanLogs = useMemo(
|
|
156
329
|
() => selectedSpan ? logState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
157
330
|
[selectedSpan, logState.data],
|
|
158
331
|
)
|
|
159
332
|
|
|
160
|
-
|
|
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"
|
|
161
340
|
|
|
162
341
|
return (
|
|
163
342
|
<box width={width ?? 100} height={height ?? 24} flexGrow={1} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)}>
|
|
164
|
-
<
|
|
165
|
-
<TextLine>
|
|
166
|
-
<span fg={colors.muted} attributes={TextAttributes.BOLD}>MOTEL</span>
|
|
167
|
-
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
168
|
-
<span fg={colors.muted}>{headerServiceLabel}</span>
|
|
169
|
-
{attrFilterLabel ? <span fg={colors.accent} attributes={TextAttributes.BOLD}>{attrFilterLabel}</span> : null}
|
|
170
|
-
<span fg={colors.muted}>{" ".repeat(headerGap)}</span>
|
|
171
|
-
<span fg={colors.muted} attributes={TextAttributes.BOLD}>{headerRight}</span>
|
|
172
|
-
</TextLine>
|
|
173
|
-
</box>
|
|
343
|
+
<AppHeader {...headerModel} />
|
|
174
344
|
{showSplit
|
|
175
345
|
? <SplitDivider leftWidth={leftPaneWidth} junction={"┬"} rightWidth={rightPaneWidth} />
|
|
176
346
|
: <Divider width={contentWidth} />}
|
|
@@ -179,6 +349,8 @@ export const App = () => {
|
|
|
179
349
|
detailView={detailView}
|
|
180
350
|
filterMode={filterMode}
|
|
181
351
|
filterText={filterText}
|
|
352
|
+
waterfallFilterMode={waterfallFilterMode}
|
|
353
|
+
waterfallFilterText={waterfallFilterText}
|
|
182
354
|
traceListProps={traceListProps}
|
|
183
355
|
selectedTraceService={selectedTraceService}
|
|
184
356
|
serviceLogState={serviceLogState}
|
|
@@ -193,35 +365,43 @@ export const App = () => {
|
|
|
193
365
|
viewLevel={viewLevel}
|
|
194
366
|
selectedSpan={selectedSpan}
|
|
195
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)}
|
|
196
378
|
selectSpan={selectSpan}
|
|
197
379
|
/>
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
/>
|
|
224
|
-
) : 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
|
+
/>
|
|
225
405
|
</box>
|
|
226
406
|
)
|
|
227
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)
|