@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.
- package/AGENTS.md +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { memo, useRef } from "react"
|
|
2
|
+
import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
|
|
3
|
+
import { formatDuration, lifecycleLabel, truncateText } from "./format.ts"
|
|
4
|
+
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
5
|
+
import { colors, waterfallColors } from "./theme.ts"
|
|
6
|
+
|
|
7
|
+
/** Filter spans to only those visible given a set of collapsed span IDs. */
|
|
8
|
+
export const getVisibleSpans = (spans: readonly TraceSpanItem[], collapsedIds: ReadonlySet<string>): readonly TraceSpanItem[] => {
|
|
9
|
+
if (collapsedIds.size === 0) return spans
|
|
10
|
+
const result: TraceSpanItem[] = []
|
|
11
|
+
let skipDepth = -1
|
|
12
|
+
for (const span of spans) {
|
|
13
|
+
if (skipDepth >= 0 && span.depth > skipDepth) continue
|
|
14
|
+
skipDepth = -1
|
|
15
|
+
result.push(span)
|
|
16
|
+
if (collapsedIds.has(span.spanId)) {
|
|
17
|
+
skipDepth = span.depth
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return result
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Find the index of a span's parent in the visible list. */
|
|
24
|
+
export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
25
|
+
const span = spans[index]
|
|
26
|
+
if (!span || span.depth === 0) return null
|
|
27
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
28
|
+
if (spans[i]!.depth < span.depth) return i
|
|
29
|
+
}
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Find the index of a span's first child in the visible list. */
|
|
34
|
+
export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
35
|
+
const span = spans[index]
|
|
36
|
+
const next = spans[index + 1]
|
|
37
|
+
if (span && next && next.depth > span.depth) return index + 1
|
|
38
|
+
return null
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const INTERESTING_TAGS = [
|
|
42
|
+
"http.method", "http.url", "http.status_code", "http.route",
|
|
43
|
+
"db.system", "db.statement", "db.name",
|
|
44
|
+
"messaging.system", "messaging.destination",
|
|
45
|
+
"error", "error.message",
|
|
46
|
+
"net.peer.name", "net.peer.port",
|
|
47
|
+
] as const
|
|
48
|
+
|
|
49
|
+
const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
|
|
50
|
+
const span = spans[index]
|
|
51
|
+
if (span.depth === 0) return ""
|
|
52
|
+
|
|
53
|
+
const parts: string[] = []
|
|
54
|
+
|
|
55
|
+
const isLastChild = (spanIndex: number, depth: number): boolean => {
|
|
56
|
+
for (let i = spanIndex + 1; i < spans.length; i++) {
|
|
57
|
+
if (spans[i].depth < depth) return true
|
|
58
|
+
if (spans[i].depth === depth) return false
|
|
59
|
+
}
|
|
60
|
+
return true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
|
|
64
|
+
|
|
65
|
+
for (let d = span.depth - 1; d >= 1; d--) {
|
|
66
|
+
let parentIndex = index
|
|
67
|
+
for (let i = index - 1; i >= 0; i--) {
|
|
68
|
+
if (spans[i].depth === d) {
|
|
69
|
+
parentIndex = i
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
if (spans[i].depth < d) break
|
|
73
|
+
}
|
|
74
|
+
parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return parts.reverse().join("")
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
|
|
81
|
+
const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
|
|
82
|
+
|
|
83
|
+
type WaterfallBarSegment = {
|
|
84
|
+
readonly text: string
|
|
85
|
+
readonly fg: string
|
|
86
|
+
readonly bg?: string
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const renderWaterfallBar = (
|
|
90
|
+
span: TraceSpanItem,
|
|
91
|
+
trace: TraceItem,
|
|
92
|
+
barWidth: number,
|
|
93
|
+
barColor: string,
|
|
94
|
+
laneColor: string,
|
|
95
|
+
): { readonly segments: readonly WaterfallBarSegment[]; readonly afterCells: number } => {
|
|
96
|
+
if (barWidth < 3 || trace.durationMs === 0) {
|
|
97
|
+
return {
|
|
98
|
+
segments: [{ text: "\u2588", fg: barColor }],
|
|
99
|
+
afterCells: Math.max(0, barWidth - 1),
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const traceStart = trace.startedAt.getTime()
|
|
104
|
+
const spanStart = span.startTime.getTime()
|
|
105
|
+
const relativeStart = Math.max(0, spanStart - traceStart)
|
|
106
|
+
const startFrac = relativeStart / trace.durationMs
|
|
107
|
+
const endFrac = Math.min(1, Math.max(startFrac, (relativeStart + Math.max(0, span.durationMs)) / trace.durationMs))
|
|
108
|
+
const totalUnits = barWidth * 8
|
|
109
|
+
const startUnits = Math.max(0, Math.min(totalUnits - 1, Math.floor(startFrac * totalUnits)))
|
|
110
|
+
const endUnits = Math.max(startUnits + 1, Math.min(totalUnits, Math.ceil(endFrac * totalUnits)))
|
|
111
|
+
const startCell = Math.floor(startUnits / 8)
|
|
112
|
+
const endCell = Math.floor((endUnits - 1) / 8)
|
|
113
|
+
const startOffset = startUnits % 8
|
|
114
|
+
const endOffset = endUnits % 8
|
|
115
|
+
const segments: WaterfallBarSegment[] = []
|
|
116
|
+
|
|
117
|
+
if (startCell > 0) {
|
|
118
|
+
segments.push({ text: " ".repeat(startCell), fg: laneColor, bg: laneColor })
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (startCell === endCell) {
|
|
122
|
+
const singleCellUnits = Math.max(1, endUnits - startUnits)
|
|
123
|
+
if (singleCellUnits <= 4) {
|
|
124
|
+
const centeredMarker = ULTRA_SHORT_MARKERS[Math.max(0, singleCellUnits - 1)] ?? "\u258f"
|
|
125
|
+
segments.push({ text: centeredMarker, fg: barColor })
|
|
126
|
+
return {
|
|
127
|
+
segments,
|
|
128
|
+
afterCells: Math.max(0, barWidth - startCell - 1),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (startOffset === 0) {
|
|
133
|
+
segments.push({ text: PARTIAL_BLOCKS[singleCellUnits], fg: barColor })
|
|
134
|
+
} else {
|
|
135
|
+
segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
segments,
|
|
139
|
+
afterCells: Math.max(0, barWidth - startCell - 1),
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (startOffset > 0) {
|
|
144
|
+
segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const fullStartCell = startCell + (startOffset > 0 ? 1 : 0)
|
|
148
|
+
const fullEndCell = endCell - (endOffset > 0 ? 1 : 0)
|
|
149
|
+
const fullCells = Math.max(0, fullEndCell - fullStartCell + 1)
|
|
150
|
+
if (fullCells > 0) {
|
|
151
|
+
segments.push({ text: "\u2588".repeat(fullCells), fg: barColor })
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
if (endOffset > 0) {
|
|
155
|
+
segments.push({ text: PARTIAL_BLOCKS[endOffset], fg: barColor })
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
segments,
|
|
160
|
+
afterCells: Math.max(0, barWidth - endCell - 1),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const durationColor = (durationMs: number) => {
|
|
165
|
+
if (durationMs >= 10_000) return colors.warning
|
|
166
|
+
if (durationMs >= 1_000) return colors.accent
|
|
167
|
+
if (durationMs >= 100) return colors.count
|
|
168
|
+
if (durationMs > 0) return colors.muted
|
|
169
|
+
return colors.muted
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const getWaterfallLayout = (contentWidth: number, traceDurationMs: number, includeLogs = true) => {
|
|
173
|
+
const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
|
|
174
|
+
const durationWidth = Math.max(8, formatDuration(traceDurationMs).length + 1)
|
|
175
|
+
const logWidth = includeLogs ? 5 : 0
|
|
176
|
+
const barWidth = Math.max(6, contentWidth - labelMaxWidth - durationWidth - logWidth - 2)
|
|
177
|
+
return { labelMaxWidth, durationWidth, logWidth, barWidth } as const
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const getWaterfallColumns = (contentWidth: number, traceDurationMs: number, durationMs: number, logCount: number, includeLogs = true) => {
|
|
181
|
+
const { labelMaxWidth, durationWidth, logWidth, barWidth } = getWaterfallLayout(contentWidth, traceDurationMs, includeLogs)
|
|
182
|
+
const durationCell = formatDuration(Math.max(0, durationMs)).padStart(durationWidth)
|
|
183
|
+
const logText = logCount > 0 ? `${logCount}lg` : ""
|
|
184
|
+
const logCell = logText.padStart(logWidth)
|
|
185
|
+
return { labelMaxWidth, durationWidth, logWidth, barWidth, durationCell, logCell } as const
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
189
|
+
const entries = Object.entries(span.tags)
|
|
190
|
+
const interesting = entries.filter(([key]) =>
|
|
191
|
+
INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
|
|
192
|
+
)
|
|
193
|
+
const rest = entries.filter(([key]) =>
|
|
194
|
+
!INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
|
|
195
|
+
)
|
|
196
|
+
const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
|
|
197
|
+
if (logs.length > 0) {
|
|
198
|
+
tagResults.push({ key: "logs", value: `${logs.length} correlated` })
|
|
199
|
+
tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
tagResults.push(...[...interesting, ...rest]
|
|
203
|
+
.slice(0, maxEntries - span.warnings.length)
|
|
204
|
+
.map(([key, value]) => ({ key, value })))
|
|
205
|
+
for (const warning of span.warnings) {
|
|
206
|
+
tagResults.push({ key: "warning", value: warning, isWarning: true })
|
|
207
|
+
}
|
|
208
|
+
return tagResults.slice(0, maxEntries)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const WaterfallRow = memo(({
|
|
212
|
+
span,
|
|
213
|
+
logCount,
|
|
214
|
+
trace,
|
|
215
|
+
index,
|
|
216
|
+
spans,
|
|
217
|
+
contentWidth,
|
|
218
|
+
selected,
|
|
219
|
+
collapsed,
|
|
220
|
+
hasChildSpans,
|
|
221
|
+
onSelect,
|
|
222
|
+
includeLogs,
|
|
223
|
+
}: {
|
|
224
|
+
span: TraceSpanItem
|
|
225
|
+
logCount: number
|
|
226
|
+
trace: TraceItem
|
|
227
|
+
index: number
|
|
228
|
+
spans: readonly TraceSpanItem[]
|
|
229
|
+
contentWidth: number
|
|
230
|
+
selected: boolean
|
|
231
|
+
collapsed: boolean
|
|
232
|
+
hasChildSpans: boolean
|
|
233
|
+
onSelect: () => void
|
|
234
|
+
includeLogs: boolean
|
|
235
|
+
}) => {
|
|
236
|
+
const prefix = buildTreePrefix(spans, index)
|
|
237
|
+
// Match the trace list indicator: `!` on error, chevron on collapsible parents, `·` on leaves.
|
|
238
|
+
const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
|
|
239
|
+
const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
|
|
240
|
+
|
|
241
|
+
const { labelMaxWidth, barWidth, durationCell, logCell } = getWaterfallColumns(contentWidth, trace.durationMs, span.durationMs, logCount, includeLogs)
|
|
242
|
+
|
|
243
|
+
const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
|
|
244
|
+
const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
|
|
245
|
+
const labelLen = prefix.length + 2 + opTruncated.length
|
|
246
|
+
const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
|
|
247
|
+
|
|
248
|
+
const isError = span.status === "error"
|
|
249
|
+
const barColor = selected ? (isError ? waterfallColors.barSelectedError : waterfallColors.barSelected) : isError ? waterfallColors.barError : waterfallColors.bar
|
|
250
|
+
const laneColor = selected ? waterfallColors.barLane : waterfallColors.barBg
|
|
251
|
+
const { segments, afterCells } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor)
|
|
252
|
+
const bg = selected ? colors.selectedBg : undefined
|
|
253
|
+
const treeColor = selected ? colors.separator : colors.treeLine
|
|
254
|
+
const indicatorColor = isError ? colors.error : hasChildSpans ? (selected ? colors.selectedText : colors.muted) : colors.passing
|
|
255
|
+
const opColor = selected ? colors.selectedText : span.isRunning ? colors.warning : colors.text
|
|
256
|
+
|
|
257
|
+
const durationFg = durationColor(span.durationMs)
|
|
258
|
+
const logFg = logCount > 0 ? colors.defaultService : colors.muted
|
|
259
|
+
|
|
260
|
+
return (
|
|
261
|
+
<box height={1} onMouseDown={onSelect}>
|
|
262
|
+
<TextLine bg={bg}>
|
|
263
|
+
{prefix ? <span fg={treeColor}>{prefix}</span> : null}
|
|
264
|
+
<span fg={indicatorColor}>{indicator}</span>
|
|
265
|
+
<span fg={opColor}>{` ${opTruncated}`}</span>
|
|
266
|
+
<span>{labelPad}</span>
|
|
267
|
+
<span> </span>
|
|
268
|
+
{segments.map((segment, index) => (
|
|
269
|
+
<span key={`${span.spanId}-bar-${index}`} fg={segment.fg} bg={segment.bg}>{segment.text}</span>
|
|
270
|
+
))}
|
|
271
|
+
{afterCells > 0 ? <span fg={laneColor} bg={laneColor}>{" ".repeat(afterCells)}</span> : null}
|
|
272
|
+
<span> </span>
|
|
273
|
+
<span fg={durationFg}>{durationCell}</span>
|
|
274
|
+
{logCell.length > 0 ? <span fg={logFg}>{logCell}</span> : null}
|
|
275
|
+
</TextLine>
|
|
276
|
+
</box>
|
|
277
|
+
)
|
|
278
|
+
})
|
|
279
|
+
WaterfallRow.displayName = "WaterfallRow"
|
|
280
|
+
|
|
281
|
+
export const SpanPreview = ({
|
|
282
|
+
span,
|
|
283
|
+
logs,
|
|
284
|
+
contentWidth,
|
|
285
|
+
maxLines,
|
|
286
|
+
}: {
|
|
287
|
+
span: TraceSpanItem
|
|
288
|
+
logs: readonly LogItem[]
|
|
289
|
+
contentWidth: number
|
|
290
|
+
maxLines: number
|
|
291
|
+
}) => {
|
|
292
|
+
const entries = spanPreviewEntries(span, logs, maxLines)
|
|
293
|
+
if (entries.length === 0) return null
|
|
294
|
+
|
|
295
|
+
const maxKeyLen = Math.min(22, entries.reduce((max, e) => Math.max(max, e.key.length), 0))
|
|
296
|
+
const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 3)
|
|
297
|
+
const indent = " ".repeat(maxKeyLen + 2)
|
|
298
|
+
|
|
299
|
+
const lines: Array<{ keyPart: string; valPart: string; isWarning?: boolean }> = []
|
|
300
|
+
for (const entry of entries) {
|
|
301
|
+
const keyStr = entry.key.length > maxKeyLen ? `${entry.key.slice(0, maxKeyLen - 1)}\u2026` : entry.key.padEnd(maxKeyLen)
|
|
302
|
+
const val = entry.value
|
|
303
|
+
if (val.length <= valMaxWidth) {
|
|
304
|
+
lines.push({ keyPart: keyStr, valPart: val, isWarning: entry.isWarning })
|
|
305
|
+
} else {
|
|
306
|
+
let remaining = val
|
|
307
|
+
let first = true
|
|
308
|
+
while (remaining.length > 0) {
|
|
309
|
+
const chunk = remaining.slice(0, valMaxWidth)
|
|
310
|
+
remaining = remaining.slice(valMaxWidth)
|
|
311
|
+
lines.push({ keyPart: first ? keyStr : indent, valPart: chunk, isWarning: entry.isWarning })
|
|
312
|
+
first = false
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return (
|
|
318
|
+
<box flexDirection="column">
|
|
319
|
+
{lines.slice(0, maxLines).map((line, i) => (
|
|
320
|
+
<TextLine key={`preview-${i}`}>
|
|
321
|
+
<span fg={line.isWarning ? colors.error : colors.previewKey}>{line.keyPart}</span>
|
|
322
|
+
<span fg={colors.separator}> </span>
|
|
323
|
+
<span fg={line.isWarning ? colors.error : colors.muted}>{line.valPart}</span>
|
|
324
|
+
</TextLine>
|
|
325
|
+
))}
|
|
326
|
+
</box>
|
|
327
|
+
)
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
export const WaterfallTimeline = ({
|
|
331
|
+
trace,
|
|
332
|
+
filteredSpans,
|
|
333
|
+
spanLogCounts,
|
|
334
|
+
selectedSpanLogs,
|
|
335
|
+
contentWidth,
|
|
336
|
+
bodyLines,
|
|
337
|
+
selectedSpanIndex,
|
|
338
|
+
collapsedSpanIds,
|
|
339
|
+
onSelectSpan,
|
|
340
|
+
}: {
|
|
341
|
+
trace: TraceItem
|
|
342
|
+
filteredSpans: readonly TraceSpanItem[]
|
|
343
|
+
spanLogCounts: ReadonlyMap<string, number>
|
|
344
|
+
selectedSpanLogs: readonly LogItem[]
|
|
345
|
+
contentWidth: number
|
|
346
|
+
bodyLines: number
|
|
347
|
+
selectedSpanIndex: number | null
|
|
348
|
+
collapsedSpanIds: ReadonlySet<string>
|
|
349
|
+
onSelectSpan: (index: number) => void
|
|
350
|
+
}) => {
|
|
351
|
+
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
352
|
+
const includeLogs = filteredSpans.some((span) => (spanLogCounts.get(span.spanId) ?? 0) > 0)
|
|
353
|
+
|
|
354
|
+
const spanIndexById = new Map<string, number>()
|
|
355
|
+
for (let i = 0; i < trace.spans.length; i++) {
|
|
356
|
+
spanIndexById.set(trace.spans[i].spanId, i)
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// Virtual windowing: only render visible rows, shift window only when
|
|
360
|
+
// the selection would go out of view (no jerkiness).
|
|
361
|
+
const viewportSize = Math.max(1, bodyLines)
|
|
362
|
+
const scrollOffsetRef = useRef(0)
|
|
363
|
+
const lastTraceIdRef = useRef<string | null>(null)
|
|
364
|
+
|
|
365
|
+
// Reset scroll offset when the trace changes
|
|
366
|
+
if (trace.traceId !== lastTraceIdRef.current) {
|
|
367
|
+
scrollOffsetRef.current = 0
|
|
368
|
+
lastTraceIdRef.current = trace.traceId
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Only shift the window when the selection would be outside it
|
|
372
|
+
if (selectedSpanIndex !== null) {
|
|
373
|
+
if (selectedSpanIndex < scrollOffsetRef.current) {
|
|
374
|
+
scrollOffsetRef.current = selectedSpanIndex
|
|
375
|
+
} else if (selectedSpanIndex >= scrollOffsetRef.current + viewportSize) {
|
|
376
|
+
scrollOffsetRef.current = selectedSpanIndex - viewportSize + 1
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
scrollOffsetRef.current = Math.max(0, Math.min(scrollOffsetRef.current, Math.max(0, filteredSpans.length - viewportSize)))
|
|
380
|
+
|
|
381
|
+
const windowStart = scrollOffsetRef.current
|
|
382
|
+
const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
|
|
383
|
+
const blankCount = Math.max(0, viewportSize - windowSpans.length)
|
|
384
|
+
|
|
385
|
+
return (
|
|
386
|
+
<box flexDirection="column">
|
|
387
|
+
{windowSpans.map((span, index) => {
|
|
388
|
+
const actualIndex = windowStart + index
|
|
389
|
+
const fullIndex = spanIndexById.get(span.spanId) ?? -1
|
|
390
|
+
return (
|
|
391
|
+
<WaterfallRow
|
|
392
|
+
key={`${trace.traceId}-${span.spanId}`}
|
|
393
|
+
span={span}
|
|
394
|
+
logCount={spanLogCounts.get(span.spanId) ?? 0}
|
|
395
|
+
trace={trace}
|
|
396
|
+
index={fullIndex}
|
|
397
|
+
spans={trace.spans}
|
|
398
|
+
contentWidth={contentWidth}
|
|
399
|
+
selected={selectedSpanIndex === actualIndex}
|
|
400
|
+
collapsed={collapsedSpanIds.has(span.spanId)}
|
|
401
|
+
hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
|
|
402
|
+
includeLogs={includeLogs}
|
|
403
|
+
onSelect={() => onSelectSpan(actualIndex)}
|
|
404
|
+
/>
|
|
405
|
+
)
|
|
406
|
+
})}
|
|
407
|
+
{Array.from({ length: blankCount }, (_, i) => (
|
|
408
|
+
<BlankRow key={`blank-${i}`} />
|
|
409
|
+
))}
|
|
410
|
+
</box>
|
|
411
|
+
)
|
|
412
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { RefObject } from "react"
|
|
3
|
+
import { FilterBar } from "../primitives.tsx"
|
|
4
|
+
import { TraceList, type TraceListProps } from "../TraceList.tsx"
|
|
5
|
+
|
|
6
|
+
interface TraceListPaneProps {
|
|
7
|
+
readonly traceListProps: TraceListProps
|
|
8
|
+
readonly filterMode: boolean
|
|
9
|
+
readonly filterText: string
|
|
10
|
+
readonly filterWidth: number
|
|
11
|
+
readonly containerHeight: number
|
|
12
|
+
readonly bodyHeight: number
|
|
13
|
+
readonly padding: number
|
|
14
|
+
readonly scrollRef: RefObject<ScrollBoxRenderable | null>
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const TraceListPane = ({
|
|
18
|
+
traceListProps,
|
|
19
|
+
filterMode,
|
|
20
|
+
filterText,
|
|
21
|
+
filterWidth,
|
|
22
|
+
containerHeight,
|
|
23
|
+
bodyHeight,
|
|
24
|
+
padding,
|
|
25
|
+
scrollRef,
|
|
26
|
+
}: TraceListPaneProps) => (
|
|
27
|
+
<box height={containerHeight} flexDirection="column" paddingLeft={padding} paddingRight={padding}>
|
|
28
|
+
<TraceList showHeader {...traceListProps} />
|
|
29
|
+
{filterMode ? <FilterBar text={filterText} width={filterWidth} /> : null}
|
|
30
|
+
<scrollbox ref={scrollRef} height={filterMode ? bodyHeight - 1 : bodyHeight} flexGrow={0}>
|
|
31
|
+
<TraceList showHeader={false} {...traceListProps} />
|
|
32
|
+
</scrollbox>
|
|
33
|
+
</box>
|
|
34
|
+
)
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import type { RefObject } from "react"
|
|
3
|
+
import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
|
|
4
|
+
import { formatShortDate, formatTimestamp } from "../format.ts"
|
|
5
|
+
import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from "../primitives.tsx"
|
|
6
|
+
import { ServiceLogsView } from "../ServiceLogs.tsx"
|
|
7
|
+
import { SpanDetailPane } from "../SpanDetailPane.tsx"
|
|
8
|
+
import type { DetailView, LogState, ServiceLogState, TraceDetailState } from "../state.ts"
|
|
9
|
+
import { colors, SEPARATOR } from "../theme.ts"
|
|
10
|
+
import { TraceDetailsPane } from "../TraceDetailsPane.tsx"
|
|
11
|
+
import type { TraceListProps } from "../TraceList.tsx"
|
|
12
|
+
import { TraceListPane } from "./TraceListPane.tsx"
|
|
13
|
+
import type { AppLayout } from "./useAppLayout.ts"
|
|
14
|
+
|
|
15
|
+
const separatorJunctionChars = new Map<number, string>([[3, "├"]])
|
|
16
|
+
const separatorCrossChars = new Map<number, string>([[3, "┼"]])
|
|
17
|
+
|
|
18
|
+
interface TraceWorkspaceProps {
|
|
19
|
+
readonly layout: AppLayout
|
|
20
|
+
readonly detailView: DetailView
|
|
21
|
+
readonly filterMode: boolean
|
|
22
|
+
readonly filterText: string
|
|
23
|
+
readonly traceListProps: TraceListProps
|
|
24
|
+
readonly traceListScrollRef: RefObject<ScrollBoxRenderable | null>
|
|
25
|
+
readonly selectedTraceService: string | null
|
|
26
|
+
readonly serviceLogState: ServiceLogState
|
|
27
|
+
readonly selectedServiceLogIndex: number
|
|
28
|
+
readonly setSelectedServiceLogIndex: (value: number | ((current: number) => number)) => void
|
|
29
|
+
readonly traceDetailState: TraceDetailState
|
|
30
|
+
readonly selectedTrace: TraceItem | null
|
|
31
|
+
readonly selectedTraceSummary: TraceSummaryItem | null
|
|
32
|
+
readonly logState: LogState
|
|
33
|
+
readonly selectedSpanIndex: number | null
|
|
34
|
+
readonly collapsedSpanIds: ReadonlySet<string>
|
|
35
|
+
readonly viewLevel: 0 | 1 | 2
|
|
36
|
+
readonly selectedSpan: TraceItem["spans"][number] | null
|
|
37
|
+
readonly selectedSpanLogs: readonly LogItem[]
|
|
38
|
+
readonly selectSpan: (index: number) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const TraceWorkspace = ({
|
|
42
|
+
layout,
|
|
43
|
+
detailView,
|
|
44
|
+
filterMode,
|
|
45
|
+
filterText,
|
|
46
|
+
traceListProps,
|
|
47
|
+
traceListScrollRef,
|
|
48
|
+
selectedTraceService,
|
|
49
|
+
serviceLogState,
|
|
50
|
+
selectedServiceLogIndex,
|
|
51
|
+
setSelectedServiceLogIndex,
|
|
52
|
+
traceDetailState,
|
|
53
|
+
selectedTrace,
|
|
54
|
+
selectedTraceSummary,
|
|
55
|
+
logState,
|
|
56
|
+
selectedSpanIndex,
|
|
57
|
+
collapsedSpanIds,
|
|
58
|
+
viewLevel,
|
|
59
|
+
selectedSpan,
|
|
60
|
+
selectedSpanLogs,
|
|
61
|
+
selectSpan,
|
|
62
|
+
}: TraceWorkspaceProps) => {
|
|
63
|
+
const {
|
|
64
|
+
contentWidth,
|
|
65
|
+
headerFooterWidth,
|
|
66
|
+
isWideLayout,
|
|
67
|
+
leftPaneWidth,
|
|
68
|
+
rightPaneWidth,
|
|
69
|
+
leftContentWidth,
|
|
70
|
+
rightContentWidth,
|
|
71
|
+
sectionPadding,
|
|
72
|
+
wideBodyHeight,
|
|
73
|
+
wideBodyLines,
|
|
74
|
+
narrowListHeight,
|
|
75
|
+
narrowBodyLines,
|
|
76
|
+
narrowFullBodyLines,
|
|
77
|
+
wideTraceListBodyHeight,
|
|
78
|
+
narrowTraceListBodyHeight,
|
|
79
|
+
availableContentHeight,
|
|
80
|
+
} = layout
|
|
81
|
+
|
|
82
|
+
if (detailView === "service-logs") {
|
|
83
|
+
return (
|
|
84
|
+
<box flexGrow={1} flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
85
|
+
<AlignedHeaderLine
|
|
86
|
+
left="SERVICE LOGS"
|
|
87
|
+
right={`${serviceLogState.data.length} logs${serviceLogState.fetchedAt ? `${SEPARATOR}${formatShortDate(serviceLogState.fetchedAt)} ${formatTimestamp(serviceLogState.fetchedAt)}` : ""}`}
|
|
88
|
+
width={headerFooterWidth}
|
|
89
|
+
rightFg={colors.count}
|
|
90
|
+
/>
|
|
91
|
+
<TextLine>
|
|
92
|
+
<span fg={colors.defaultService}>{selectedTraceService ?? "unknown"}</span>
|
|
93
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
94
|
+
<span fg={colors.count}>recent logs</span>
|
|
95
|
+
</TextLine>
|
|
96
|
+
<BlankRow />
|
|
97
|
+
<ServiceLogsView
|
|
98
|
+
serviceName={selectedTraceService}
|
|
99
|
+
logsState={serviceLogState}
|
|
100
|
+
selectedIndex={selectedServiceLogIndex}
|
|
101
|
+
onSelectLog={setSelectedServiceLogIndex}
|
|
102
|
+
contentWidth={headerFooterWidth}
|
|
103
|
+
bodyLines={Math.max(8, availableContentHeight - 3)}
|
|
104
|
+
/>
|
|
105
|
+
</box>
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (isWideLayout) {
|
|
110
|
+
return (
|
|
111
|
+
<box flexGrow={1} flexDirection="row">
|
|
112
|
+
<box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
113
|
+
{viewLevel <= 1 ? (
|
|
114
|
+
<TraceListPane
|
|
115
|
+
traceListProps={traceListProps}
|
|
116
|
+
filterMode={filterMode}
|
|
117
|
+
filterText={filterText}
|
|
118
|
+
filterWidth={leftContentWidth}
|
|
119
|
+
containerHeight={wideBodyHeight}
|
|
120
|
+
bodyHeight={wideTraceListBodyHeight}
|
|
121
|
+
padding={sectionPadding}
|
|
122
|
+
scrollRef={traceListScrollRef}
|
|
123
|
+
/>
|
|
124
|
+
) : (
|
|
125
|
+
<TraceDetailsPane
|
|
126
|
+
trace={selectedTrace}
|
|
127
|
+
traceSummary={selectedTraceSummary}
|
|
128
|
+
traceStatus={traceDetailState.status}
|
|
129
|
+
traceError={traceDetailState.error}
|
|
130
|
+
traceLogsState={logState}
|
|
131
|
+
contentWidth={leftContentWidth}
|
|
132
|
+
bodyLines={wideBodyLines}
|
|
133
|
+
paneWidth={leftPaneWidth}
|
|
134
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
135
|
+
collapsedSpanIds={collapsedSpanIds}
|
|
136
|
+
focused={false}
|
|
137
|
+
onSelectSpan={selectSpan}
|
|
138
|
+
/>
|
|
139
|
+
)}
|
|
140
|
+
</box>
|
|
141
|
+
<SeparatorColumn height={wideBodyHeight} junctionChars={viewLevel === 2 ? separatorCrossChars : separatorJunctionChars} />
|
|
142
|
+
<box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
143
|
+
{viewLevel <= 1 ? (
|
|
144
|
+
<TraceDetailsPane
|
|
145
|
+
trace={selectedTrace}
|
|
146
|
+
traceSummary={selectedTraceSummary}
|
|
147
|
+
traceStatus={traceDetailState.status}
|
|
148
|
+
traceError={traceDetailState.error}
|
|
149
|
+
traceLogsState={logState}
|
|
150
|
+
contentWidth={rightContentWidth}
|
|
151
|
+
bodyLines={wideBodyLines}
|
|
152
|
+
paneWidth={rightPaneWidth}
|
|
153
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
154
|
+
collapsedSpanIds={collapsedSpanIds}
|
|
155
|
+
focused={viewLevel === 1}
|
|
156
|
+
onSelectSpan={selectSpan}
|
|
157
|
+
/>
|
|
158
|
+
) : (
|
|
159
|
+
<SpanDetailPane
|
|
160
|
+
span={selectedSpan}
|
|
161
|
+
trace={selectedTrace}
|
|
162
|
+
logs={selectedSpanLogs}
|
|
163
|
+
contentWidth={rightContentWidth}
|
|
164
|
+
bodyLines={wideBodyLines}
|
|
165
|
+
paneWidth={rightPaneWidth}
|
|
166
|
+
focused={true}
|
|
167
|
+
/>
|
|
168
|
+
)}
|
|
169
|
+
</box>
|
|
170
|
+
</box>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (viewLevel === 0) {
|
|
175
|
+
return (
|
|
176
|
+
<>
|
|
177
|
+
<TraceListPane
|
|
178
|
+
traceListProps={traceListProps}
|
|
179
|
+
filterMode={filterMode}
|
|
180
|
+
filterText={filterText}
|
|
181
|
+
filterWidth={leftContentWidth}
|
|
182
|
+
containerHeight={narrowListHeight}
|
|
183
|
+
bodyHeight={narrowTraceListBodyHeight}
|
|
184
|
+
padding={sectionPadding}
|
|
185
|
+
scrollRef={traceListScrollRef}
|
|
186
|
+
/>
|
|
187
|
+
<Divider width={contentWidth} />
|
|
188
|
+
<TraceDetailsPane
|
|
189
|
+
trace={selectedTrace}
|
|
190
|
+
traceSummary={selectedTraceSummary}
|
|
191
|
+
traceStatus={traceDetailState.status}
|
|
192
|
+
traceError={traceDetailState.error}
|
|
193
|
+
traceLogsState={logState}
|
|
194
|
+
contentWidth={rightContentWidth}
|
|
195
|
+
bodyLines={narrowBodyLines}
|
|
196
|
+
paneWidth={contentWidth}
|
|
197
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
198
|
+
collapsedSpanIds={collapsedSpanIds}
|
|
199
|
+
focused={false}
|
|
200
|
+
onSelectSpan={selectSpan}
|
|
201
|
+
/>
|
|
202
|
+
</>
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return (
|
|
207
|
+
<>
|
|
208
|
+
<box paddingLeft={1} paddingRight={1} height={1} flexDirection="column">
|
|
209
|
+
<TextLine>
|
|
210
|
+
<span fg={colors.muted}>TRACES</span>
|
|
211
|
+
{selectedTraceSummary ? (
|
|
212
|
+
<>
|
|
213
|
+
<span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
|
|
214
|
+
<span fg={viewLevel === 1 ? colors.accent : colors.muted}>{selectedTraceSummary.rootOperationName}</span>
|
|
215
|
+
</>
|
|
216
|
+
) : null}
|
|
217
|
+
{viewLevel === 2 && selectedSpan ? (
|
|
218
|
+
<>
|
|
219
|
+
<span fg={colors.separator}>{" "}{SEPARATOR}{" "}</span>
|
|
220
|
+
<span fg={colors.accent}>{selectedSpan.operationName}</span>
|
|
221
|
+
</>
|
|
222
|
+
) : null}
|
|
223
|
+
</TextLine>
|
|
224
|
+
</box>
|
|
225
|
+
<Divider width={contentWidth} />
|
|
226
|
+
{viewLevel === 1 ? (
|
|
227
|
+
<TraceDetailsPane
|
|
228
|
+
trace={selectedTrace}
|
|
229
|
+
traceSummary={selectedTraceSummary}
|
|
230
|
+
traceStatus={traceDetailState.status}
|
|
231
|
+
traceError={traceDetailState.error}
|
|
232
|
+
traceLogsState={logState}
|
|
233
|
+
contentWidth={rightContentWidth}
|
|
234
|
+
bodyLines={narrowFullBodyLines}
|
|
235
|
+
paneWidth={contentWidth}
|
|
236
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
237
|
+
collapsedSpanIds={collapsedSpanIds}
|
|
238
|
+
focused={true}
|
|
239
|
+
onSelectSpan={selectSpan}
|
|
240
|
+
/>
|
|
241
|
+
) : (
|
|
242
|
+
<SpanDetailPane
|
|
243
|
+
span={selectedSpan}
|
|
244
|
+
trace={selectedTrace}
|
|
245
|
+
logs={selectedSpanLogs}
|
|
246
|
+
contentWidth={rightContentWidth}
|
|
247
|
+
bodyLines={narrowFullBodyLines}
|
|
248
|
+
paneWidth={contentWidth}
|
|
249
|
+
focused={true}
|
|
250
|
+
/>
|
|
251
|
+
)}
|
|
252
|
+
</>
|
|
253
|
+
)
|
|
254
|
+
}
|