@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,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
+ }