@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
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import type { TraceSpanItem } from "../domain.ts"
|
|
3
|
+
import { computeMatchingSpanIds, findAdjacentMatch, spanMatchesFilter } from "./waterfallFilter.ts"
|
|
4
|
+
|
|
5
|
+
const span = (spanId: string, operationName: string, tags: Record<string, string> = {}): TraceSpanItem => ({
|
|
6
|
+
spanId,
|
|
7
|
+
parentSpanId: null,
|
|
8
|
+
serviceName: "test",
|
|
9
|
+
scopeName: null,
|
|
10
|
+
kind: null,
|
|
11
|
+
operationName,
|
|
12
|
+
startTime: new Date(0),
|
|
13
|
+
isRunning: false,
|
|
14
|
+
durationMs: 1,
|
|
15
|
+
status: "ok",
|
|
16
|
+
depth: 0,
|
|
17
|
+
tags,
|
|
18
|
+
warnings: [],
|
|
19
|
+
events: [],
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe("spanMatchesFilter", () => {
|
|
23
|
+
it("matches operation name case-insensitively", () => {
|
|
24
|
+
expect(spanMatchesFilter(span("a", "ai.StreamText"), "stream")).toBe(true)
|
|
25
|
+
expect(spanMatchesFilter(span("a", "ai.StreamText"), "nope")).toBe(false)
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it("matches tag values but not keys", () => {
|
|
29
|
+
expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "claude")).toBe(true)
|
|
30
|
+
// Key-only match should not count, otherwise searching "ai" dims nothing.
|
|
31
|
+
expect(spanMatchesFilter(span("a", "op", { "ai.model.id": "claude" }), "model.id")).toBe(false)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it("returns true when the needle is empty", () => {
|
|
35
|
+
expect(spanMatchesFilter(span("a", "op"), "")).toBe(true)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe("computeMatchingSpanIds", () => {
|
|
40
|
+
it("returns null for empty/whitespace filter", () => {
|
|
41
|
+
expect(computeMatchingSpanIds([span("a", "op")], "")).toBeNull()
|
|
42
|
+
expect(computeMatchingSpanIds([span("a", "op")], " ")).toBeNull()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("returns only matching span ids", () => {
|
|
46
|
+
const spans = [span("a", "ai.streamText"), span("b", "Agent.get"), span("c", "ai.toolCall")]
|
|
47
|
+
const ids = computeMatchingSpanIds(spans, "ai")
|
|
48
|
+
expect(ids).not.toBeNull()
|
|
49
|
+
expect(Array.from(ids!)).toEqual(["a", "c"])
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe("findAdjacentMatch", () => {
|
|
54
|
+
const spans = [span("a", "one"), span("b", "two"), span("c", "three"), span("d", "four")]
|
|
55
|
+
const matches = new Set(["b", "d"])
|
|
56
|
+
|
|
57
|
+
it("finds next from current selection", () => {
|
|
58
|
+
expect(findAdjacentMatch(spans, matches, 0, 1)).toBe(1) // a -> b
|
|
59
|
+
expect(findAdjacentMatch(spans, matches, 1, 1)).toBe(3) // b -> d
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it("wraps forward past the end", () => {
|
|
63
|
+
expect(findAdjacentMatch(spans, matches, 3, 1)).toBe(1) // d -> b (wrap)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it("finds previous from current selection", () => {
|
|
67
|
+
expect(findAdjacentMatch(spans, matches, 3, -1)).toBe(1) // d -> b
|
|
68
|
+
expect(findAdjacentMatch(spans, matches, 1, -1)).toBe(3) // b -> d (wrap)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("starts from beginning/end when nothing is selected", () => {
|
|
72
|
+
expect(findAdjacentMatch(spans, matches, null, 1)).toBe(1) // forward → first match
|
|
73
|
+
expect(findAdjacentMatch(spans, matches, null, -1)).toBe(3) // backward → last match
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it("returns null when there are no matches", () => {
|
|
77
|
+
expect(findAdjacentMatch(spans, new Set(), 0, 1)).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it("handles a single match by returning it regardless of direction", () => {
|
|
81
|
+
expect(findAdjacentMatch(spans, new Set(["c"]), 0, 1)).toBe(2)
|
|
82
|
+
expect(findAdjacentMatch(spans, new Set(["c"]), 0, -1)).toBe(2)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { TraceSpanItem } from "../domain.ts"
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Case-insensitive substring match against a span's operation name and
|
|
5
|
+
* any of its tag values. Keys aren't checked — they're dotted
|
|
6
|
+
* identifiers the user never types, so matching them just creates false
|
|
7
|
+
* positives on bare tokens like "ai" or "response".
|
|
8
|
+
*/
|
|
9
|
+
export const spanMatchesFilter = (span: TraceSpanItem, needle: string): boolean => {
|
|
10
|
+
if (!needle) return true
|
|
11
|
+
if (span.operationName.toLowerCase().includes(needle)) return true
|
|
12
|
+
for (const value of Object.values(span.tags)) {
|
|
13
|
+
if (typeof value === "string" && value.toLowerCase().includes(needle)) return true
|
|
14
|
+
}
|
|
15
|
+
return false
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Compute the set of span IDs that match the given filter text. Returns
|
|
20
|
+
* null when the filter is empty so callers can skip dimming entirely
|
|
21
|
+
* (hot path during waterfall scrolling).
|
|
22
|
+
*/
|
|
23
|
+
export const computeMatchingSpanIds = (
|
|
24
|
+
spans: readonly TraceSpanItem[],
|
|
25
|
+
filterText: string,
|
|
26
|
+
): ReadonlySet<string> | null => {
|
|
27
|
+
const needle = filterText.trim().toLowerCase()
|
|
28
|
+
if (!needle) return null
|
|
29
|
+
const matches = new Set<string>()
|
|
30
|
+
for (const span of spans) {
|
|
31
|
+
if (spanMatchesFilter(span, needle)) matches.add(span.spanId)
|
|
32
|
+
}
|
|
33
|
+
return matches
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Find the next (direction=1) or previous (direction=-1) matching span
|
|
38
|
+
* index in `filteredSpans` relative to `currentIndex`. Wraps around the
|
|
39
|
+
* ends. Returns `null` when the list has no matches at all. When
|
|
40
|
+
* `currentIndex` is null (nothing selected), starts from either end
|
|
41
|
+
* based on direction.
|
|
42
|
+
*/
|
|
43
|
+
export const findAdjacentMatch = (
|
|
44
|
+
filteredSpans: readonly TraceSpanItem[],
|
|
45
|
+
matchingSpanIds: ReadonlySet<string>,
|
|
46
|
+
currentIndex: number | null,
|
|
47
|
+
direction: 1 | -1,
|
|
48
|
+
): number | null => {
|
|
49
|
+
if (filteredSpans.length === 0 || matchingSpanIds.size === 0) return null
|
|
50
|
+
const start = currentIndex ?? (direction === 1 ? -1 : filteredSpans.length)
|
|
51
|
+
const n = filteredSpans.length
|
|
52
|
+
// Walk the ring exactly once so we wrap but never loop forever.
|
|
53
|
+
for (let step = 1; step <= n; step++) {
|
|
54
|
+
const idx = ((start + direction * step) % n + n) % n
|
|
55
|
+
const span = filteredSpans[idx]
|
|
56
|
+
if (span && matchingSpanIds.has(span.spanId)) return idx
|
|
57
|
+
}
|
|
58
|
+
return null
|
|
59
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { LogItem, TraceSpanItem } from "../domain.ts"
|
|
2
|
+
import { formatDuration } from "./format.ts"
|
|
3
|
+
|
|
4
|
+
/** Filter spans to only those visible given a set of collapsed span IDs. */
|
|
5
|
+
export const getVisibleSpans = (spans: readonly TraceSpanItem[], collapsedIds: ReadonlySet<string>): readonly TraceSpanItem[] => {
|
|
6
|
+
if (collapsedIds.size === 0) return spans
|
|
7
|
+
const result: TraceSpanItem[] = []
|
|
8
|
+
let skipDepth = -1
|
|
9
|
+
for (const span of spans) {
|
|
10
|
+
if (skipDepth >= 0 && span.depth > skipDepth) continue
|
|
11
|
+
skipDepth = -1
|
|
12
|
+
result.push(span)
|
|
13
|
+
if (collapsedIds.has(span.spanId)) {
|
|
14
|
+
skipDepth = span.depth
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return result
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Find the index of a span's parent in the visible list. */
|
|
21
|
+
export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
22
|
+
const span = spans[index]
|
|
23
|
+
if (!span || span.depth === 0) return null
|
|
24
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
25
|
+
if (spans[i]!.depth < span.depth) return i
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Find the index of a span's first child in the visible list. */
|
|
31
|
+
export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
32
|
+
const span = spans[index]
|
|
33
|
+
const next = spans[index + 1]
|
|
34
|
+
if (span && next && next.depth > span.depth) return index + 1
|
|
35
|
+
return null
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
|
|
39
|
+
const span = spans[index]
|
|
40
|
+
if (span.depth === 0) return ""
|
|
41
|
+
|
|
42
|
+
const parts: string[] = []
|
|
43
|
+
|
|
44
|
+
const isLastChild = (spanIndex: number, depth: number): boolean => {
|
|
45
|
+
for (let i = spanIndex + 1; i < spans.length; i++) {
|
|
46
|
+
if (spans[i].depth < depth) return true
|
|
47
|
+
if (spans[i].depth === depth) return false
|
|
48
|
+
}
|
|
49
|
+
return true
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
|
|
53
|
+
|
|
54
|
+
for (let d = span.depth - 1; d >= 1; d--) {
|
|
55
|
+
let parentIndex = index
|
|
56
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
57
|
+
if (spans[i].depth === d) {
|
|
58
|
+
parentIndex = i
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
if (spans[i].depth < d) break
|
|
62
|
+
}
|
|
63
|
+
parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
return parts.reverse().join("")
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const INTERESTING_TAGS = [
|
|
70
|
+
"http.method", "http.url", "http.status_code", "http.route",
|
|
71
|
+
"db.system", "db.statement", "db.name",
|
|
72
|
+
"messaging.system", "messaging.destination",
|
|
73
|
+
"error", "error.message",
|
|
74
|
+
"net.peer.name", "net.peer.port",
|
|
75
|
+
] as const
|
|
76
|
+
|
|
77
|
+
export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
|
|
78
|
+
const gapsAndSuffix = suffixWidth + 2
|
|
79
|
+
const remaining = Math.max(4, contentWidth - gapsAndSuffix)
|
|
80
|
+
const labelMaxWidth = Math.max(4, Math.min(Math.floor(remaining * 0.5), 32))
|
|
81
|
+
const barWidth = Math.max(1, contentWidth - labelMaxWidth - gapsAndSuffix)
|
|
82
|
+
return { labelMaxWidth, barWidth } as const
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type WaterfallSuffixMetrics = {
|
|
86
|
+
readonly maxDurationWidth: number
|
|
87
|
+
readonly suffixWidth: number
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export const getWaterfallSuffixMetrics = (
|
|
91
|
+
spans: readonly { readonly durationMs: number; readonly spanId: string }[],
|
|
92
|
+
): WaterfallSuffixMetrics => {
|
|
93
|
+
let maxDurationWidth = 0
|
|
94
|
+
for (const span of spans) {
|
|
95
|
+
const d = formatDuration(Math.max(0, span.durationMs)).length
|
|
96
|
+
if (d > maxDurationWidth) maxDurationWidth = d
|
|
97
|
+
}
|
|
98
|
+
return { maxDurationWidth, suffixWidth: maxDurationWidth }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const getWaterfallColumns = (
|
|
102
|
+
contentWidth: number,
|
|
103
|
+
metrics: WaterfallSuffixMetrics,
|
|
104
|
+
) => {
|
|
105
|
+
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
|
|
106
|
+
return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
110
|
+
const entries = Object.entries(span.tags)
|
|
111
|
+
const interesting = entries.filter(([key]) =>
|
|
112
|
+
INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
|
|
113
|
+
)
|
|
114
|
+
const rest = entries.filter(([key]) =>
|
|
115
|
+
!INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
|
|
116
|
+
)
|
|
117
|
+
const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
|
|
118
|
+
if (logs.length > 0) {
|
|
119
|
+
tagResults.push({ key: "logs", value: `${logs.length} correlated` })
|
|
120
|
+
tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
tagResults.push(...[...interesting, ...rest]
|
|
124
|
+
.slice(0, maxEntries - span.warnings.length)
|
|
125
|
+
.map(([key, value]) => ({ key, value })))
|
|
126
|
+
for (const warning of span.warnings) {
|
|
127
|
+
tagResults.push({ key: "warning", value: warning, isWarning: true })
|
|
128
|
+
}
|
|
129
|
+
return tagResults.slice(0, maxEntries)
|
|
130
|
+
}
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
getWaterfallLayout,
|
|
7
7
|
getWaterfallSuffixMetrics,
|
|
8
8
|
getVisibleSpans,
|
|
9
|
-
} from "./
|
|
9
|
+
} from "./waterfallModel.ts"
|
|
10
10
|
import { resolveCollapseStep } from "./waterfallNav.ts"
|
|
11
11
|
|
|
12
12
|
// ---------------------------------------------------------------------------
|
|
@@ -153,6 +153,22 @@ describe("getWaterfallSuffixMetrics", () => {
|
|
|
153
153
|
// label + 1 (gap before bar) + bar + 1 (gap before suffix) + suffix = contentWidth
|
|
154
154
|
expect(labelMaxWidth + 1 + barWidth + 1 + metrics.suffixWidth).toBe(contentWidth)
|
|
155
155
|
})
|
|
156
|
+
|
|
157
|
+
it("layout fits inside contentWidth at narrow widths without overflow", () => {
|
|
158
|
+
// Regression guard: a prior `max(6, ...)` floor on barWidth caused
|
|
159
|
+
// the total row width to exceed contentWidth at narrow panes,
|
|
160
|
+
// which in turn made OpenTUI's truncate add "..." suffixes
|
|
161
|
+
// across the right edge. Every width in this sweep must satisfy
|
|
162
|
+
// label + 1 + bar + 1 + suffix == contentWidth.
|
|
163
|
+
for (let contentWidth = 14; contentWidth <= 120; contentWidth++) {
|
|
164
|
+
for (const suffixWidth of [3, 5, 7]) {
|
|
165
|
+
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixWidth)
|
|
166
|
+
expect(labelMaxWidth + 1 + barWidth + 1 + suffixWidth).toBe(contentWidth)
|
|
167
|
+
expect(barWidth).toBeGreaterThanOrEqual(1)
|
|
168
|
+
expect(labelMaxWidth).toBeGreaterThanOrEqual(4)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
})
|
|
156
172
|
})
|
|
157
173
|
|
|
158
174
|
// ---------------------------------------------------------------------------
|
package/src/ui/waterfallNav.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TraceSpanItem } from "../domain.ts"
|
|
2
|
-
import { findFirstChildIndex, findParentIndex, getVisibleSpans } from "./
|
|
2
|
+
import { findFirstChildIndex, findParentIndex, getVisibleSpans } from "./waterfallModel.ts"
|
|
3
3
|
|
|
4
4
|
export type CollapseStep = {
|
|
5
5
|
readonly collapsed: ReadonlySet<string>
|