@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.
Files changed (55) hide show
  1. package/AGENTS.md +11 -1
  2. package/package.json +5 -3
  3. package/src/App.tsx +239 -59
  4. package/src/daemon.test.ts +144 -7
  5. package/src/daemon.ts +113 -8
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +62 -4
  8. package/src/httpApi.ts +4 -1
  9. package/src/localServer.ts +112 -121
  10. package/src/mcp.ts +172 -0
  11. package/src/motelClient.ts +166 -14
  12. package/src/registry.ts +26 -23
  13. package/src/runtime.ts +8 -2
  14. package/src/server.ts +10 -9
  15. package/src/services/AsyncIngest.ts +52 -0
  16. package/src/services/TelemetryStore.ts +285 -27
  17. package/src/services/TraceQueryService.ts +4 -2
  18. package/src/services/ingestRpc.ts +41 -0
  19. package/src/services/telemetryWorker.ts +62 -0
  20. package/src/storybook/aiChatStory.tsx +243 -0
  21. package/src/storybook/fixtures/errorState.ts +44 -0
  22. package/src/storybook/fixtures/imagePaste.ts +34 -0
  23. package/src/storybook/fixtures/index.ts +62 -0
  24. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  25. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  26. package/src/storybook/fixtures/short.ts +27 -0
  27. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  28. package/src/telemetry.test.ts +61 -0
  29. package/src/ui/AiChatView.tsx +292 -0
  30. package/src/ui/SpanContentView.tsx +181 -0
  31. package/src/ui/SpanDetail.tsx +98 -17
  32. package/src/ui/TraceDetailsPane.tsx +35 -3
  33. package/src/ui/Waterfall.tsx +94 -167
  34. package/src/ui/aiChatModel.test.ts +347 -0
  35. package/src/ui/aiChatModel.ts +736 -0
  36. package/src/ui/aiState.ts +71 -0
  37. package/src/ui/app/TraceWorkspace.tsx +295 -120
  38. package/src/ui/app/useAppLayout.ts +14 -11
  39. package/src/ui/app/useTraceScreenData.ts +191 -35
  40. package/src/ui/atoms.ts +131 -0
  41. package/src/ui/filterParser.test.ts +56 -0
  42. package/src/ui/filterParser.ts +45 -0
  43. package/src/ui/loaders.ts +120 -0
  44. package/src/ui/persistence.ts +41 -0
  45. package/src/ui/primitives.tsx +47 -21
  46. package/src/ui/state.ts +4 -169
  47. package/src/ui/useAttrFilterPicker.ts +63 -23
  48. package/src/ui/useKeyboardNav.ts +576 -300
  49. package/src/ui/waterfallFilter.test.ts +84 -0
  50. package/src/ui/waterfallFilter.ts +59 -0
  51. package/src/ui/waterfallModel.ts +130 -0
  52. package/src/ui/waterfallNav.test.ts +17 -1
  53. package/src/ui/waterfallNav.ts +1 -1
  54. package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
  55. 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 "./Waterfall.tsx"
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
  // ---------------------------------------------------------------------------
@@ -1,5 +1,5 @@
1
1
  import type { TraceSpanItem } from "../domain.ts"
2
- import { findFirstChildIndex, findParentIndex, getVisibleSpans } from "./Waterfall.tsx"
2
+ import { findFirstChildIndex, findParentIndex, getVisibleSpans } from "./waterfallModel.ts"
3
3
 
4
4
  export type CollapseStep = {
5
5
  readonly collapsed: ReadonlySet<string>