@kitlangton/motel 0.2.0 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +5 -0
- package/package.json +7 -5
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +213 -6
- package/src/daemon.ts +174 -38
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +114 -128
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +68 -0
- package/src/services/TelemetryStore.ts +262 -119
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +244 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +11 -28
- package/src/ui/Waterfall.tsx +43 -148
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +288 -124
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +174 -40
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
|
@@ -2,9 +2,10 @@ import { useMemo } from "react"
|
|
|
2
2
|
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, formatShortDate, formatTimestamp } from "./format.ts"
|
|
4
4
|
import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./primitives.tsx"
|
|
5
|
-
import {
|
|
5
|
+
import { WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
6
|
import { computeMatchingSpanIds } from "./waterfallFilter.ts"
|
|
7
|
-
import
|
|
7
|
+
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
8
|
+
import type { LoadStatus } from "./state.ts"
|
|
8
9
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -23,13 +24,12 @@ export const TraceDetailsPane = ({
|
|
|
23
24
|
traceSummary,
|
|
24
25
|
traceStatus,
|
|
25
26
|
traceError,
|
|
26
|
-
|
|
27
|
+
traceLogCount,
|
|
27
28
|
contentWidth,
|
|
28
29
|
bodyLines,
|
|
29
30
|
paneWidth,
|
|
30
31
|
selectedSpanIndex,
|
|
31
32
|
collapsedSpanIds,
|
|
32
|
-
focused = false,
|
|
33
33
|
onSelectSpan,
|
|
34
34
|
waterfallFilterMode,
|
|
35
35
|
waterfallFilterText,
|
|
@@ -38,13 +38,12 @@ export const TraceDetailsPane = ({
|
|
|
38
38
|
traceSummary: TraceSummaryItem | null
|
|
39
39
|
traceStatus: LoadStatus
|
|
40
40
|
traceError: string | null
|
|
41
|
-
|
|
41
|
+
traceLogCount: number
|
|
42
42
|
contentWidth: number
|
|
43
43
|
bodyLines: number
|
|
44
44
|
paneWidth: number
|
|
45
45
|
selectedSpanIndex: number | null
|
|
46
46
|
collapsedSpanIds: ReadonlySet<string>
|
|
47
|
-
focused?: boolean
|
|
48
47
|
onSelectSpan: (index: number) => void
|
|
49
48
|
waterfallFilterMode: boolean
|
|
50
49
|
waterfallFilterText: string
|
|
@@ -53,20 +52,6 @@ export const TraceDetailsPane = ({
|
|
|
53
52
|
() => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
|
|
54
53
|
[trace, collapsedSpanIds],
|
|
55
54
|
)
|
|
56
|
-
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
57
|
-
const traceLogCount = traceLogsState.data.length
|
|
58
|
-
const spanLogCounts = useMemo(() => {
|
|
59
|
-
const counts = new Map<string, number>()
|
|
60
|
-
for (const log of traceLogsState.data) {
|
|
61
|
-
if (!log.spanId) continue
|
|
62
|
-
counts.set(log.spanId, (counts.get(log.spanId) ?? 0) + 1)
|
|
63
|
-
}
|
|
64
|
-
return counts
|
|
65
|
-
}, [traceLogsState.data])
|
|
66
|
-
const selectedSpanLogs = useMemo(
|
|
67
|
-
() => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
68
|
-
[selectedSpan, traceLogsState.data],
|
|
69
|
-
)
|
|
70
55
|
const matchingSpanIds = useMemo(
|
|
71
56
|
() => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
|
|
72
57
|
[trace, waterfallFilterText],
|
|
@@ -143,14 +128,12 @@ export const TraceDetailsPane = ({
|
|
|
143
128
|
</box>
|
|
144
129
|
) : null}
|
|
145
130
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
bodyLines={waterfallBodyLines}
|
|
153
|
-
selectedSpanIndex={selectedSpanIndex}
|
|
131
|
+
<WaterfallTimeline
|
|
132
|
+
trace={trace}
|
|
133
|
+
filteredSpans={filteredSpans}
|
|
134
|
+
contentWidth={contentWidth}
|
|
135
|
+
bodyLines={waterfallBodyLines}
|
|
136
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
154
137
|
collapsedSpanIds={collapsedSpanIds}
|
|
155
138
|
matchingSpanIds={matchingSpanIds}
|
|
156
139
|
onSelectSpan={onSelectSpan}
|
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -1,81 +1,17 @@
|
|
|
1
|
-
import { memo, useLayoutEffect,
|
|
2
|
-
import type
|
|
1
|
+
import { memo, useLayoutEffect, useState } from "react"
|
|
2
|
+
import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
|
|
4
4
|
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
5
5
|
import { colors, waterfallColors } from "./theme.ts"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
result.push(span)
|
|
16
|
-
if (collapsedIds.has(span.spanId)) {
|
|
17
|
-
skipDepth = span.depth
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return result
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Find the index of a span's parent in the visible list. */
|
|
24
|
-
export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
25
|
-
const span = spans[index]
|
|
26
|
-
if (!span || span.depth === 0) return null
|
|
27
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
28
|
-
if (spans[i]!.depth < span.depth) return i
|
|
29
|
-
}
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Find the index of a span's first child in the visible list. */
|
|
34
|
-
export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
35
|
-
const span = spans[index]
|
|
36
|
-
const next = spans[index + 1]
|
|
37
|
-
if (span && next && next.depth > span.depth) return index + 1
|
|
38
|
-
return null
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const INTERESTING_TAGS = [
|
|
42
|
-
"http.method", "http.url", "http.status_code", "http.route",
|
|
43
|
-
"db.system", "db.statement", "db.name",
|
|
44
|
-
"messaging.system", "messaging.destination",
|
|
45
|
-
"error", "error.message",
|
|
46
|
-
"net.peer.name", "net.peer.port",
|
|
47
|
-
] as const
|
|
48
|
-
|
|
49
|
-
const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
|
|
50
|
-
const span = spans[index]
|
|
51
|
-
if (span.depth === 0) return ""
|
|
52
|
-
|
|
53
|
-
const parts: string[] = []
|
|
54
|
-
|
|
55
|
-
const isLastChild = (spanIndex: number, depth: number): boolean => {
|
|
56
|
-
for (let i = spanIndex + 1; i < spans.length; i++) {
|
|
57
|
-
if (spans[i].depth < depth) return true
|
|
58
|
-
if (spans[i].depth === depth) return false
|
|
59
|
-
}
|
|
60
|
-
return true
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
|
|
64
|
-
|
|
65
|
-
for (let d = span.depth - 1; d >= 1; d--) {
|
|
66
|
-
let parentIndex = index
|
|
67
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
68
|
-
if (spans[i].depth === d) {
|
|
69
|
-
parentIndex = i
|
|
70
|
-
break
|
|
71
|
-
}
|
|
72
|
-
if (spans[i].depth < d) break
|
|
73
|
-
}
|
|
74
|
-
parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return parts.reverse().join("")
|
|
78
|
-
}
|
|
6
|
+
export { getVisibleSpans } from "./waterfallModel.ts"
|
|
7
|
+
import {
|
|
8
|
+
buildTreePrefix,
|
|
9
|
+
findFirstChildIndex,
|
|
10
|
+
getWaterfallLayout,
|
|
11
|
+
getWaterfallSuffixMetrics,
|
|
12
|
+
type WaterfallSuffixMetrics,
|
|
13
|
+
spanPreviewEntries,
|
|
14
|
+
} from "./waterfallModel.ts"
|
|
79
15
|
|
|
80
16
|
const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
|
|
81
17
|
const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
|
|
@@ -180,66 +116,6 @@ const durationColor = (durationMs: number) => {
|
|
|
180
116
|
return colors.muted
|
|
181
117
|
}
|
|
182
118
|
|
|
183
|
-
export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
|
|
184
|
-
const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
|
|
185
|
-
// Two single-space gaps: one between label and bar, one between bar and suffix.
|
|
186
|
-
const barWidth = Math.max(6, contentWidth - labelMaxWidth - suffixWidth - 2)
|
|
187
|
-
return { labelMaxWidth, barWidth } as const
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export type WaterfallSuffixMetrics = {
|
|
191
|
-
readonly maxDurationWidth: number
|
|
192
|
-
readonly suffixWidth: number
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Compute a shared suffix (duration) width from the visible viewport.
|
|
197
|
-
* Reserving the width once keeps every row's duration right-aligned on the
|
|
198
|
-
* same column regardless of per-row content. Log correlation lives in the
|
|
199
|
-
* span detail pane, not the row suffix.
|
|
200
|
-
*/
|
|
201
|
-
export const getWaterfallSuffixMetrics = (
|
|
202
|
-
spans: readonly { readonly durationMs: number; readonly spanId: string }[],
|
|
203
|
-
): WaterfallSuffixMetrics => {
|
|
204
|
-
let maxDurationWidth = 0
|
|
205
|
-
for (const span of spans) {
|
|
206
|
-
const d = formatDuration(Math.max(0, span.durationMs)).length
|
|
207
|
-
if (d > maxDurationWidth) maxDurationWidth = d
|
|
208
|
-
}
|
|
209
|
-
return { maxDurationWidth, suffixWidth: maxDurationWidth }
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Retained for tests: per-row view of the shared layout.
|
|
213
|
-
export const getWaterfallColumns = (
|
|
214
|
-
contentWidth: number,
|
|
215
|
-
metrics: WaterfallSuffixMetrics,
|
|
216
|
-
) => {
|
|
217
|
-
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
|
|
218
|
-
return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
222
|
-
const entries = Object.entries(span.tags)
|
|
223
|
-
const interesting = entries.filter(([key]) =>
|
|
224
|
-
INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
|
|
225
|
-
)
|
|
226
|
-
const rest = entries.filter(([key]) =>
|
|
227
|
-
!INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
|
|
228
|
-
)
|
|
229
|
-
const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
|
|
230
|
-
if (logs.length > 0) {
|
|
231
|
-
tagResults.push({ key: "logs", value: `${logs.length} correlated` })
|
|
232
|
-
tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
tagResults.push(...[...interesting, ...rest]
|
|
236
|
-
.slice(0, maxEntries - span.warnings.length)
|
|
237
|
-
.map(([key, value]) => ({ key, value })))
|
|
238
|
-
for (const warning of span.warnings) {
|
|
239
|
-
tagResults.push({ key: "warning", value: warning, isWarning: true })
|
|
240
|
-
}
|
|
241
|
-
return tagResults.slice(0, maxEntries)
|
|
242
|
-
}
|
|
243
119
|
|
|
244
120
|
const WaterfallRow = memo(({
|
|
245
121
|
span,
|
|
@@ -267,14 +143,33 @@ const WaterfallRow = memo(({
|
|
|
267
143
|
onSelect: () => void
|
|
268
144
|
}) => {
|
|
269
145
|
const prefix = buildTreePrefix(spans, index)
|
|
270
|
-
|
|
271
|
-
|
|
146
|
+
const isAi = isAiSpan(span.tags)
|
|
147
|
+
// Indicator column: `!` on error, chevron on collapsible parents,
|
|
148
|
+
// `✦` on AI leaves (LLM payloads detected — enter drills into a
|
|
149
|
+
// specialized chat view), `·` on other leaves. AI parents keep the
|
|
150
|
+
// chevron glyph so tree structure stays readable; the accent color
|
|
151
|
+
// (applied below) carries the "AI content lives here" signal.
|
|
152
|
+
const indicator = span.status === "error" ? "!"
|
|
153
|
+
: hasChildSpans ? (collapsed ? "\u25b8" : "\u25be")
|
|
154
|
+
: isAi ? "\u2726"
|
|
155
|
+
: "\u00b7"
|
|
272
156
|
const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
|
|
273
157
|
|
|
274
158
|
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
|
|
275
159
|
|
|
276
|
-
|
|
277
|
-
|
|
160
|
+
// Op name budget = labelMaxWidth minus (prefix + indicator + 1 space).
|
|
161
|
+
// Never force a minimum: at very deep nesting or narrow widths the
|
|
162
|
+
// prefix + indicator may already fill the label column, in which
|
|
163
|
+
// case we render the op as an empty string (or a lone ellipsis) so
|
|
164
|
+
// the line stays within contentWidth. Previous code forced op to 4
|
|
165
|
+
// chars which could push total row width past the pane and make
|
|
166
|
+
// OpenTUI smear "..." across the right edge.
|
|
167
|
+
const opMaxWidth = Math.max(0, labelMaxWidth - prefix.length - 2)
|
|
168
|
+
const opTruncated = opMaxWidth === 0
|
|
169
|
+
? ""
|
|
170
|
+
: opName.length > opMaxWidth
|
|
171
|
+
? `${opName.slice(0, Math.max(0, opMaxWidth - 1))}\u2026`
|
|
172
|
+
: opName
|
|
278
173
|
const labelLen = prefix.length + 2 + opTruncated.length
|
|
279
174
|
const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
|
|
280
175
|
|
|
@@ -292,6 +187,11 @@ const WaterfallRow = memo(({
|
|
|
292
187
|
const indicatorColor = selected ? colors.selectedText
|
|
293
188
|
: dimmed ? colors.separator
|
|
294
189
|
: isError ? colors.error
|
|
190
|
+
// AI accent outranks parent/leaf color so both AI parents and AI
|
|
191
|
+
// leaves scan as "there's an LLM payload here" from across the
|
|
192
|
+
// waterfall. Error still wins because a failed AI span is first
|
|
193
|
+
// and foremost a failure.
|
|
194
|
+
: isAi ? colors.accent
|
|
295
195
|
: hasChildSpans ? colors.muted
|
|
296
196
|
: colors.passing
|
|
297
197
|
const opColor = selected ? colors.selectedText
|
|
@@ -380,8 +280,6 @@ export const SpanPreview = ({
|
|
|
380
280
|
export const WaterfallTimeline = ({
|
|
381
281
|
trace,
|
|
382
282
|
filteredSpans,
|
|
383
|
-
spanLogCounts,
|
|
384
|
-
selectedSpanLogs,
|
|
385
283
|
contentWidth,
|
|
386
284
|
bodyLines,
|
|
387
285
|
selectedSpanIndex,
|
|
@@ -391,8 +289,6 @@ export const WaterfallTimeline = ({
|
|
|
391
289
|
}: {
|
|
392
290
|
trace: TraceItem
|
|
393
291
|
filteredSpans: readonly TraceSpanItem[]
|
|
394
|
-
spanLogCounts: ReadonlyMap<string, number>
|
|
395
|
-
selectedSpanLogs: readonly LogItem[]
|
|
396
292
|
contentWidth: number
|
|
397
293
|
bodyLines: number
|
|
398
294
|
selectedSpanIndex: number | null
|
|
@@ -420,13 +316,12 @@ export const WaterfallTimeline = ({
|
|
|
420
316
|
const viewportSize = Math.max(1, bodyLines)
|
|
421
317
|
const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
|
|
422
318
|
const [scrollOffset, setScrollOffset] = useState(0)
|
|
423
|
-
const lastTraceIdRef = useRef<string | null>(null)
|
|
424
319
|
|
|
425
|
-
// Reset scroll offset when the trace changes.
|
|
426
|
-
|
|
320
|
+
// Reset scroll offset when the trace changes. Keep this out of render so
|
|
321
|
+
// a trace switch doesn't force a render-phase state update on hot paths.
|
|
322
|
+
useLayoutEffect(() => {
|
|
427
323
|
setScrollOffset(0)
|
|
428
|
-
|
|
429
|
-
}
|
|
324
|
+
}, [trace.traceId])
|
|
430
325
|
|
|
431
326
|
// Auto-follow selection: only if the selected span would be hidden
|
|
432
327
|
// by the current window, shift just enough to bring it back. Runs in
|