@kitlangton/motel 0.1.0

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 (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. package/web/dist/index.html +13 -0
@@ -0,0 +1,112 @@
1
+ import type { ScrollBoxRenderable } from "@opentui/core"
2
+ import { useLayoutEffect, useRef } from "react"
3
+ import type { LogItem } from "../domain.ts"
4
+ import { fitCell, formatLogTimestamp, formatTimestamp, logHeadline, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
5
+ import { Divider, PlainLine, TextLine } from "./primitives.tsx"
6
+ import type { ServiceLogState } from "./state.ts"
7
+ import { colors, SEPARATOR } from "./theme.ts"
8
+
9
+ export const ServiceLogsView = ({
10
+ serviceName,
11
+ logsState,
12
+ selectedIndex,
13
+ onSelectLog,
14
+ contentWidth,
15
+ bodyLines,
16
+ }: {
17
+ serviceName: string | null
18
+ logsState: ServiceLogState
19
+ selectedIndex: number
20
+ onSelectLog: (index: number) => void
21
+ contentWidth: number
22
+ bodyLines: number
23
+ }) => {
24
+ const timeWidth = 8
25
+ const levelWidth = 5
26
+ const traceWidth = 8
27
+ const messageWidth = Math.max(16, contentWidth - timeWidth - levelWidth - traceWidth - 3)
28
+
29
+ if (logsState.status === "loading" && logsState.data.length === 0) {
30
+ return <PlainLine text="Loading recent service logs..." fg={colors.count} />
31
+ }
32
+
33
+ if (logsState.status === "error") {
34
+ return <PlainLine text={logsState.error ?? "Could not load logs."} fg={colors.error} />
35
+ }
36
+
37
+ if (logsState.data.length === 0) {
38
+ return <PlainLine text={`No logs captured yet for service ${serviceName ?? "unknown"}.`} fg={colors.muted} />
39
+ }
40
+
41
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
42
+ const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
43
+ const selectedLog = logsState.data[safeSelectedIndex] ?? null
44
+ const detailWidth = Math.max(16, contentWidth - 2)
45
+ const detailBodyLines = selectedLog ? wrapTextLines(selectedLog.body, detailWidth, 6) : []
46
+ const detailAttributeLines = selectedLog ? relevantLogAttributes(selectedLog).slice(0, 3) : []
47
+ const detailHeight = selectedLog ? 3 + detailBodyLines.length + detailAttributeLines.length : 0
48
+ const listHeight = Math.max(4, bodyLines - detailHeight - 1)
49
+
50
+ useLayoutEffect(() => {
51
+ scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
52
+ }, [safeSelectedIndex])
53
+
54
+ return (
55
+ <box flexDirection="column">
56
+ {selectedLog ? (
57
+ <>
58
+ <TextLine>
59
+ <span fg={logSeverityColor(selectedLog.severityText)}>{selectedLog.severityText.toLowerCase()}</span>
60
+ <span fg={colors.separator}>{SEPARATOR}</span>
61
+ <span fg={colors.muted}>{formatLogTimestamp(selectedLog.timestamp)}</span>
62
+ <span fg={colors.separator}>{SEPARATOR}</span>
63
+ <span fg={colors.count}>{selectedLog.traceId ? selectedLog.traceId.slice(-8) : "no-trace"}</span>
64
+ </TextLine>
65
+ <TextLine>
66
+ <span fg={colors.defaultService}>{selectedLog.scopeName ?? selectedLog.serviceName}</span>
67
+ {selectedLog.spanId ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{selectedLog.spanId.slice(-8)}</span></> : null}
68
+ </TextLine>
69
+ {detailBodyLines.map((line, index) => (
70
+ <PlainLine key={`log-detail-${selectedLog.id}-${index}`} text={line} fg={colors.text} />
71
+ ))}
72
+ {detailAttributeLines.map(([key, value]) => (
73
+ <TextLine key={`log-attr-${selectedLog.id}-${key}`}>
74
+ <span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
75
+ <span fg={colors.muted}> </span>
76
+ <span fg={colors.muted}>{truncateText(value, Math.max(12, detailWidth - 20))}</span>
77
+ </TextLine>
78
+ ))}
79
+ <Divider width={contentWidth} />
80
+ </>
81
+ ) : null}
82
+ <TextLine fg={colors.muted}>
83
+ <span>{fitCell("time", timeWidth)}</span>
84
+ <span> </span>
85
+ <span>{fitCell("lvl", levelWidth)}</span>
86
+ <span> </span>
87
+ <span>{fitCell("trace", traceWidth)}</span>
88
+ <span> </span>
89
+ <span>{fitCell("message", messageWidth)}</span>
90
+ </TextLine>
91
+ <scrollbox ref={scrollRef} height={listHeight} flexGrow={0}>
92
+ {logsState.data.map((log, index) => {
93
+ const selected = index === safeSelectedIndex
94
+ const trace = log.traceId ? log.traceId.slice(-8) : "-"
95
+ return (
96
+ <box id={`svc-log-${index}`} key={log.id} height={1} onMouseDown={() => onSelectLog(index)}>
97
+ <TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
98
+ <span fg={colors.muted}>{fitCell(formatTimestamp(log.timestamp), timeWidth)}</span>
99
+ <span> </span>
100
+ <span fg={logSeverityColor(log.severityText)}>{fitCell(log.severityText.toLowerCase(), levelWidth)}</span>
101
+ <span> </span>
102
+ <span fg={colors.count}>{fitCell(trace, traceWidth)}</span>
103
+ <span> </span>
104
+ <span fg={selected ? colors.selectedText : colors.text}>{fitCell(logHeadline(log.body), messageWidth)}</span>
105
+ </TextLine>
106
+ </box>
107
+ )
108
+ })}
109
+ </scrollbox>
110
+ </box>
111
+ )
112
+ }
@@ -0,0 +1,134 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { LogItem, TraceSpanItem } from "../domain.ts"
3
+ import { formatTimestamp, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
4
+ import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
5
+ import { colors, SEPARATOR } from "./theme.ts"
6
+
7
+ export const SpanDetailView = ({
8
+ span,
9
+ logs,
10
+ contentWidth,
11
+ bodyLines,
12
+ }: {
13
+ span: TraceSpanItem
14
+ logs: readonly LogItem[]
15
+ contentWidth: number
16
+ bodyLines: number
17
+ }) => {
18
+ const tagEntries = Object.entries(span.tags)
19
+ const maxKeyLen = Math.min(28, tagEntries.reduce((max, [key]) => Math.max(max, key.length), 0))
20
+ const maxLogLines = logs.length > 0 ? Math.min(4, Math.max(1, Math.floor(bodyLines * 0.3))) : 0
21
+ const visibleLogs = logs.slice(0, maxLogLines)
22
+ const visibleWarnings = span.warnings.slice(0, visibleLogs.length > 0 ? 1 : 2)
23
+ const visibleEvents = span.events.slice(0, 2)
24
+ const reservedForWarnings = visibleWarnings.length > 0 ? visibleWarnings.length + 2 : 0
25
+ const reservedForEvents = visibleEvents.length > 0 ? visibleEvents.length + 2 : 0
26
+ const reservedForLogs = visibleLogs.length > 0 ? visibleLogs.reduce((total, log) => total + 3 + Math.min(3, wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3).length), 1) : 0
27
+ const maxTagLines = Math.max(0, bodyLines - 4 - reservedForWarnings - reservedForEvents - reservedForLogs)
28
+
29
+ // NOTE: op name, service, duration, lifecycle, status, and spanId are all
30
+ // rendered by the enclosing SpanDetailPane header (rows 0..2). Starting
31
+ // the body at TAGS avoids the visible duplication where the pane meta
32
+ // and the first two body lines mirrored each other.
33
+ return (
34
+ <box flexDirection="column">
35
+ {tagEntries.length > 0 ? (
36
+ <>
37
+ <TextLine>
38
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
39
+ </TextLine>
40
+ {tagEntries.slice(0, maxTagLines).map(([key, value]) => {
41
+ const keyStr = key.length > maxKeyLen ? `${key.slice(0, maxKeyLen - 1)}\u2026` : key.padEnd(maxKeyLen)
42
+ const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 2)
43
+ const valStr = value.length > valMaxWidth ? `${value.slice(0, valMaxWidth - 1)}\u2026` : value
44
+
45
+ return (
46
+ <TextLine key={key}>
47
+ <span fg={colors.count}>{keyStr}</span>
48
+ <span fg={colors.muted}> </span>
49
+ <span fg={colors.text}>{valStr}</span>
50
+ </TextLine>
51
+ )
52
+ })}
53
+ {tagEntries.length > maxTagLines ? (
54
+ <PlainLine text={` \u2026 ${tagEntries.length - maxTagLines} more`} fg={colors.muted} />
55
+ ) : null}
56
+ </>
57
+ ) : (
58
+ <PlainLine text="No tags on this span." fg={colors.muted} />
59
+ )}
60
+ {visibleWarnings.length > 0 ? (
61
+ <>
62
+ <BlankRow />
63
+ <TextLine>
64
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>WARNINGS</span>
65
+ </TextLine>
66
+ {visibleWarnings.map((warning, i) => (
67
+ <PlainLine key={i} text={warning} fg={colors.error} />
68
+ ))}
69
+ </>
70
+ ) : null}
71
+ {visibleEvents.length > 0 ? (
72
+ <>
73
+ <BlankRow />
74
+ <TextLine>
75
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>EVENTS</span>
76
+ </TextLine>
77
+ {visibleEvents.map((event, index) => {
78
+ const preview = Object.entries(event.attributes).slice(0, 1)
79
+ return (
80
+ <box key={`${event.name}-${index}`} flexDirection="column">
81
+ <TextLine>
82
+ <span fg={colors.muted}>{formatTimestamp(event.timestamp)}</span>
83
+ <span fg={colors.separator}>{SEPARATOR}</span>
84
+ <span fg={colors.text}>{event.name}</span>
85
+ </TextLine>
86
+ {preview.map(([key, value]) => (
87
+ <TextLine key={`${event.name}-${key}`}>
88
+ <span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
89
+ <span fg={colors.muted}> </span>
90
+ <span fg={colors.muted}>{truncateText(value, Math.max(12, contentWidth - 20))}</span>
91
+ </TextLine>
92
+ ))}
93
+ </box>
94
+ )
95
+ })}
96
+ </>
97
+ ) : null}
98
+ {visibleLogs.length > 0 ? (
99
+ <>
100
+ <BlankRow />
101
+ <TextLine>
102
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>RELATED LOGS</span>
103
+ </TextLine>
104
+ {visibleLogs.map((log) => {
105
+ const logBodyLines = wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3)
106
+ const attributePreview = relevantLogAttributes(log).slice(0, 1)
107
+
108
+ return (
109
+ <box key={log.id} flexDirection="column">
110
+ <TextLine>
111
+ <span fg={colors.muted}>{formatTimestamp(log.timestamp)}</span>
112
+ <span fg={colors.separator}>{SEPARATOR}</span>
113
+ <span fg={logSeverityColor(log.severityText)}>{log.severityText.toLowerCase()}</span>
114
+ <span fg={colors.separator}>{SEPARATOR}</span>
115
+ <span fg={colors.defaultService}>{log.scopeName ?? log.serviceName}</span>
116
+ </TextLine>
117
+ {logBodyLines.map((line, index) => (
118
+ <PlainLine key={`${log.id}-body-${index}`} text={line} fg={colors.text} />
119
+ ))}
120
+ {attributePreview.map(([key, value]) => (
121
+ <TextLine key={`${log.id}-${key}`}>
122
+ <span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
123
+ <span fg={colors.muted}> </span>
124
+ <span fg={colors.muted}>{truncateText(value, Math.max(12, contentWidth - 20))}</span>
125
+ </TextLine>
126
+ ))}
127
+ </box>
128
+ )
129
+ })}
130
+ </>
131
+ ) : null}
132
+ </box>
133
+ )
134
+ }
@@ -0,0 +1,224 @@
1
+ import { TextAttributes } from "@opentui/core"
2
+ import type { ReactNode } from "react"
3
+ import type { LogItem, TraceSpanItem } from "../domain.ts"
4
+ import { formatDuration, formatShortDate, formatTimestamp, lifecycleLabel, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
5
+ import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
6
+ import { colors, SEPARATOR } from "./theme.ts"
7
+
8
+ type Line = ReactNode
9
+
10
+ /**
11
+ * Full-pane span detail view. Shows everything we know about a span:
12
+ * - identifiers, timing, scope, kind, status
13
+ * - all tags
14
+ * - all events with all attributes
15
+ * - all warnings
16
+ * - all correlated logs with wrapped bodies and relevant attributes
17
+ *
18
+ * The view builds a flat array of rendered lines then slices it to `bodyLines`,
19
+ * honoring a scroll offset so `j/k` scrolling can be wired later.
20
+ */
21
+ export const SpanDetailFullView = ({
22
+ span,
23
+ logs,
24
+ contentWidth,
25
+ bodyLines,
26
+ scrollOffset = 0,
27
+ }: {
28
+ span: TraceSpanItem
29
+ logs: readonly LogItem[]
30
+ contentWidth: number
31
+ bodyLines: number
32
+ scrollOffset?: number
33
+ }) => {
34
+ const lines: Line[] = []
35
+ let key = 0
36
+ const push = (node: Line) => lines.push(<box key={key++}>{node}</box>)
37
+
38
+ // --- Header: operation + status line ---
39
+ push(
40
+ <TextLine>
41
+ <span fg={colors.text} attributes={TextAttributes.BOLD}>{span.operationName}</span>
42
+ </TextLine>,
43
+ )
44
+ push(
45
+ <TextLine>
46
+ <span fg={colors.defaultService}>{span.serviceName}</span>
47
+ <span fg={colors.separator}>{SEPARATOR}</span>
48
+ <span fg={colors.count}>{formatDuration(span.durationMs)}</span>
49
+ <span fg={colors.separator}>{SEPARATOR}</span>
50
+ <span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
51
+ <span fg={colors.separator}>{SEPARATOR}</span>
52
+ <span fg={span.status === "error" ? colors.error : colors.passing}>{span.status}</span>
53
+ </TextLine>,
54
+ )
55
+ push(<BlankRow />)
56
+
57
+ // --- Metadata block ---
58
+ const metaKeyWidth = 10
59
+ const metaRow = (k: string, v: string, vFg: string = colors.text) => (
60
+ <TextLine>
61
+ <span fg={colors.count}>{k.padEnd(metaKeyWidth)}</span>
62
+ <span fg={colors.muted}> </span>
63
+ <span fg={vFg}>{truncateText(v, Math.max(8, contentWidth - metaKeyWidth - 1))}</span>
64
+ </TextLine>
65
+ )
66
+ push(<TextLine><span fg={colors.accent} attributes={TextAttributes.BOLD}>META</span></TextLine>)
67
+ push(metaRow("span id", span.spanId))
68
+ if (span.parentSpanId) push(metaRow("parent", span.parentSpanId))
69
+ if (span.kind) push(metaRow("kind", span.kind))
70
+ if (span.scopeName) push(metaRow("scope", span.scopeName))
71
+ push(metaRow("started", `${formatShortDate(span.startTime)} ${formatTimestamp(span.startTime)}`))
72
+ push(metaRow("depth", String(span.depth), colors.muted))
73
+
74
+ // --- Tags ---
75
+ const tagEntries = Object.entries(span.tags)
76
+ if (tagEntries.length > 0) {
77
+ push(<BlankRow />)
78
+ push(
79
+ <TextLine>
80
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
81
+ <span fg={colors.muted}> ({tagEntries.length})</span>
82
+ </TextLine>,
83
+ )
84
+ const maxKeyLen = Math.min(32, tagEntries.reduce((m, [k]) => Math.max(m, k.length), 0))
85
+ const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 2)
86
+ for (const [tagKey, value] of tagEntries) {
87
+ const keyStr = tagKey.length > maxKeyLen ? `${tagKey.slice(0, maxKeyLen - 1)}\u2026` : tagKey.padEnd(maxKeyLen)
88
+ const wrapped = wrapTextLines(value, valMaxWidth, 4)
89
+ wrapped.forEach((line, idx) => {
90
+ push(
91
+ <TextLine>
92
+ <span fg={colors.count}>{idx === 0 ? keyStr : " ".repeat(maxKeyLen)}</span>
93
+ <span fg={colors.muted}> </span>
94
+ <span fg={colors.text}>{line}</span>
95
+ </TextLine>,
96
+ )
97
+ })
98
+ }
99
+ }
100
+
101
+ // --- Warnings ---
102
+ if (span.warnings.length > 0) {
103
+ push(<BlankRow />)
104
+ push(
105
+ <TextLine>
106
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>WARNINGS</span>
107
+ <span fg={colors.muted}> ({span.warnings.length})</span>
108
+ </TextLine>,
109
+ )
110
+ for (const warning of span.warnings) {
111
+ for (const line of wrapTextLines(warning, Math.max(16, contentWidth - 2), 4)) {
112
+ push(<PlainLine text={line} fg={colors.error} />)
113
+ }
114
+ }
115
+ }
116
+
117
+ // --- Events ---
118
+ if (span.events.length > 0) {
119
+ push(<BlankRow />)
120
+ push(
121
+ <TextLine>
122
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>EVENTS</span>
123
+ <span fg={colors.muted}> ({span.events.length})</span>
124
+ </TextLine>,
125
+ )
126
+ for (const event of span.events) {
127
+ push(
128
+ <TextLine>
129
+ <span fg={colors.muted}>{formatTimestamp(event.timestamp)}</span>
130
+ <span fg={colors.separator}>{SEPARATOR}</span>
131
+ <span fg={colors.text}>{event.name}</span>
132
+ </TextLine>,
133
+ )
134
+ const attrs = Object.entries(event.attributes)
135
+ if (attrs.length > 0) {
136
+ const attrKeyWidth = Math.min(24, attrs.reduce((m, [k]) => Math.max(m, k.length), 0))
137
+ const attrValWidth = Math.max(8, contentWidth - attrKeyWidth - 4)
138
+ for (const [attrKey, attrVal] of attrs) {
139
+ const wrapped = wrapTextLines(attrVal, attrValWidth, 2)
140
+ wrapped.forEach((line, idx) => {
141
+ push(
142
+ <TextLine>
143
+ <span fg={colors.muted}> </span>
144
+ <span fg={colors.count}>
145
+ {idx === 0
146
+ ? (attrKey.length > attrKeyWidth ? `${attrKey.slice(0, attrKeyWidth - 1)}\u2026` : attrKey.padEnd(attrKeyWidth))
147
+ : " ".repeat(attrKeyWidth)}
148
+ </span>
149
+ <span fg={colors.muted}> </span>
150
+ <span fg={colors.muted}>{line}</span>
151
+ </TextLine>,
152
+ )
153
+ })
154
+ }
155
+ }
156
+ }
157
+ }
158
+
159
+ // --- Logs ---
160
+ if (logs.length > 0) {
161
+ push(<BlankRow />)
162
+ push(
163
+ <TextLine>
164
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>LOGS</span>
165
+ <span fg={colors.muted}> ({logs.length})</span>
166
+ </TextLine>,
167
+ )
168
+ for (const log of logs) {
169
+ push(
170
+ <TextLine>
171
+ <span fg={colors.muted}>{formatTimestamp(log.timestamp)}</span>
172
+ <span fg={colors.separator}>{SEPARATOR}</span>
173
+ <span fg={logSeverityColor(log.severityText)}>{log.severityText.toLowerCase()}</span>
174
+ <span fg={colors.separator}>{SEPARATOR}</span>
175
+ <span fg={colors.defaultService}>{log.scopeName ?? log.serviceName}</span>
176
+ </TextLine>,
177
+ )
178
+ for (const line of wrapTextLines(log.body, Math.max(16, contentWidth - 2), 8)) {
179
+ push(<PlainLine text={line} fg={colors.text} />)
180
+ }
181
+ const attrs = relevantLogAttributes(log)
182
+ if (attrs.length > 0) {
183
+ const attrKeyWidth = Math.min(22, attrs.reduce((m, [k]) => Math.max(m, k.length), 0))
184
+ const attrValWidth = Math.max(8, contentWidth - attrKeyWidth - 4)
185
+ for (const [attrKey, attrVal] of attrs) {
186
+ const wrapped = wrapTextLines(attrVal, attrValWidth, 2)
187
+ wrapped.forEach((line, idx) => {
188
+ push(
189
+ <TextLine>
190
+ <span fg={colors.muted}> </span>
191
+ <span fg={colors.count}>
192
+ {idx === 0
193
+ ? (attrKey.length > attrKeyWidth ? `${attrKey.slice(0, attrKeyWidth - 1)}\u2026` : attrKey.padEnd(attrKeyWidth))
194
+ : " ".repeat(attrKeyWidth)}
195
+ </span>
196
+ <span fg={colors.muted}> </span>
197
+ <span fg={colors.muted}>{line}</span>
198
+ </TextLine>,
199
+ )
200
+ })
201
+ }
202
+ }
203
+ }
204
+ }
205
+
206
+ // --- Slice by scroll ---
207
+ const totalLines = lines.length
208
+ const maxOffset = Math.max(0, totalLines - bodyLines)
209
+ const offset = Math.min(Math.max(0, scrollOffset), maxOffset)
210
+ const visible = lines.slice(offset, offset + bodyLines)
211
+
212
+ return (
213
+ <box flexDirection="column">
214
+ {visible}
215
+ {totalLines > bodyLines ? (
216
+ <TextLine>
217
+ <span fg={colors.muted}>
218
+ {`\u2014 ${offset + 1}\u2013${Math.min(offset + bodyLines, totalLines)} of ${totalLines} lines${offset < maxOffset ? " \u00b7 j/k to scroll" : ""}`}
219
+ </span>
220
+ </TextLine>
221
+ ) : null}
222
+ </box>
223
+ )
224
+ }
@@ -0,0 +1,91 @@
1
+ import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
2
+ import { formatDuration, lifecycleLabel } from "./format.ts"
3
+ import { AlignedHeaderLine, Divider, PlainLine, TextLine } from "./primitives.tsx"
4
+ import { SpanDetailView } from "./SpanDetail.tsx"
5
+ import { colors, SEPARATOR } from "./theme.ts"
6
+
7
+ /**
8
+ * Level-2 view: focused span. Renders a header matching
9
+ * `TraceDetailsPane` geometry (so wide-mode side-by-side layouts line up
10
+ * vertically) plus a body that can grow to fill the available height.
11
+ *
12
+ * Total height: `bodyLines + HEADER_ROWS`.
13
+ */
14
+ export const SPAN_DETAIL_HEADER_ROWS = 4
15
+
16
+ export const SpanDetailPane = ({
17
+ span,
18
+ trace,
19
+ logs,
20
+ contentWidth,
21
+ bodyLines,
22
+ paneWidth,
23
+ focused = false,
24
+ }: {
25
+ span: TraceSpanItem | null
26
+ trace: TraceItem | null
27
+ logs: readonly LogItem[]
28
+ contentWidth: number
29
+ bodyLines: number
30
+ paneWidth: number
31
+ focused?: boolean
32
+ }) => {
33
+ const focusIndicator = focused ? "\u25b8 " : ""
34
+ const headerTitle = `${focusIndicator}SPAN`
35
+ const headerRight = span
36
+ ? `${span.status} \u00b7 ${formatDuration(span.durationMs)}${logs.length > 0 ? ` \u00b7 ${logs.length} lg` : ""}`
37
+ : "no span selected"
38
+ const headerColor = span
39
+ ? span.isRunning
40
+ ? colors.warning
41
+ : span.status === "error"
42
+ ? colors.error
43
+ : colors.passing
44
+ : colors.muted
45
+
46
+ return (
47
+ <box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_DETAIL_HEADER_ROWS} overflow="hidden">
48
+ <box paddingLeft={1} paddingRight={1}>
49
+ <AlignedHeaderLine left={headerTitle} right={headerRight} width={contentWidth} rightFg={headerColor} />
50
+ </box>
51
+ {span && trace ? (
52
+ <>
53
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
54
+ <TextLine>
55
+ <span fg={colors.text}>{span.operationName}</span>
56
+ </TextLine>
57
+ <TextLine>
58
+ <span fg={colors.defaultService}>{span.serviceName}</span>
59
+ <span fg={colors.separator}>{SEPARATOR}</span>
60
+ <span fg={colors.muted}>{span.scopeName ?? "no scope"}</span>
61
+ <span fg={colors.separator}>{SEPARATOR}</span>
62
+ <span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
63
+ <span fg={colors.separator}>{SEPARATOR}</span>
64
+ <span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
65
+ </TextLine>
66
+ </box>
67
+ <Divider width={paneWidth} />
68
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
69
+ <SpanDetailView
70
+ span={span}
71
+ logs={logs}
72
+ contentWidth={contentWidth}
73
+ bodyLines={bodyLines}
74
+ />
75
+ </box>
76
+ </>
77
+ ) : (
78
+ <>
79
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
80
+ <TextLine><span fg={colors.muted}>—</span></TextLine>
81
+ <TextLine><span fg={colors.muted}>—</span></TextLine>
82
+ </box>
83
+ <Divider width={paneWidth} />
84
+ <box flexDirection="column" paddingLeft={1} paddingRight={1}>
85
+ <PlainLine text="Select a span in the waterfall to view its detail." fg={colors.muted} />
86
+ </box>
87
+ </>
88
+ )}
89
+ </box>
90
+ )
91
+ }