@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.
Files changed (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +5 -3
  3. package/src/App.tsx +233 -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 +16 -0
  8. package/src/localServer.ts +111 -121
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +52 -0
  15. package/src/services/TelemetryStore.ts +151 -26
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +243 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +292 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +2 -1
  32. package/src/ui/Waterfall.tsx +38 -138
  33. package/src/ui/aiChatModel.test.ts +347 -0
  34. package/src/ui/aiChatModel.ts +736 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +291 -120
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +173 -39
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
@@ -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>