@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.
- package/AGENTS.md +5 -0
- package/package.json +5 -3
- package/src/App.tsx +233 -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 +16 -0
- package/src/localServer.ts +111 -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 +151 -26
- 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 +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 +28 -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 +2 -1
- package/src/ui/Waterfall.tsx +38 -138
- 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 +291 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +173 -39
- 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
|
@@ -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>
|