@kitlangton/motel 0.1.1 → 0.1.2
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 +22 -8
- package/package.json +2 -1
- package/src/App.tsx +38 -28
- package/src/config.ts +1 -1
- package/src/httpApi.ts +5 -2
- package/src/localServer.ts +1 -0
- package/src/motel.ts +12 -0
- package/src/services/TelemetryStore.ts +99 -23
- package/src/services/TraceQueryService.ts +4 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/SpanDetailPane.tsx +1 -2
- package/src/ui/TraceDetailsPane.tsx +14 -22
- package/src/ui/TraceList.tsx +166 -40
- package/src/ui/Waterfall.tsx +104 -46
- package/src/ui/app/TraceListPane.tsx +19 -14
- package/src/ui/app/TraceWorkspace.tsx +60 -31
- package/src/ui/app/useAppLayout.ts +22 -3
- package/src/ui/app/useTraceScreenData.ts +13 -2
- package/src/ui/format.ts +14 -5
- package/src/ui/primitives.tsx +3 -1
- package/src/ui/state.ts +32 -0
- package/src/ui/theme.ts +24 -19
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/useAttrFilterPicker.ts +47 -0
- package/src/ui/useKeyboardNav.ts +114 -15
- package/src/ui/waterfallNav.test.ts +22 -7
- package/web/dist/assets/{index-BEKIiisE.js → index-DKinj-OE.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -67,8 +67,7 @@ export const TraceDetailsPane = ({
|
|
|
67
67
|
const hasTraceSelection = traceSummary !== null
|
|
68
68
|
const isLoadingTrace = hasTraceSelection && trace === null && traceStatus !== "error"
|
|
69
69
|
|
|
70
|
-
const
|
|
71
|
-
const headerTitle = `${focusIndicator}TRACE DETAILS`
|
|
70
|
+
const headerTitle = "TRACE DETAILS"
|
|
72
71
|
const headerRight = traceMeta
|
|
73
72
|
? `${traceMeta.errorCount > 0 ? `${traceMeta.errorCount} errors` : traceMeta.isRunning ? "running" : isLoadingTrace ? "loading" : "healthy"} \u00b7 ${formatDuration(traceMeta.durationMs)}${traceLogCount > 0 ? ` \u00b7 ${traceLogCount} logs` : ""}`
|
|
74
73
|
: traceStatus === "error"
|
|
@@ -86,7 +85,6 @@ export const TraceDetailsPane = ({
|
|
|
86
85
|
const opLeft = traceMeta?.rootOperationName ?? ""
|
|
87
86
|
const opGap = Math.max(2, contentWidth - opLeft.length - dateStr.length)
|
|
88
87
|
const warningCount = traceMeta?.warnings.length ?? 0
|
|
89
|
-
const firstWarning = traceMeta?.warnings[0] ?? ""
|
|
90
88
|
|
|
91
89
|
return (
|
|
92
90
|
<box flexDirection="column" width={paneWidth} height={bodyLines + TRACE_DETAILS_HEADER_ROWS} overflow="hidden">
|
|
@@ -101,23 +99,17 @@ export const TraceDetailsPane = ({
|
|
|
101
99
|
<span>{" ".repeat(opGap)}</span>
|
|
102
100
|
<span fg={colors.muted}>{dateStr}</span>
|
|
103
101
|
</TextLine>
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
115
|
-
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
116
|
-
<span fg={colors.count}>{trace.spanCount} spans</span>
|
|
117
|
-
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
118
|
-
<span fg={colors.muted}>{trace.traceId.slice(0, 16)}</span>
|
|
119
|
-
</TextLine>
|
|
120
|
-
)}
|
|
102
|
+
<TextLine>
|
|
103
|
+
<span fg={colors.count}>{trace.spanCount} spans</span>
|
|
104
|
+
{warningCount > 0 ? (
|
|
105
|
+
<>
|
|
106
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
107
|
+
<span fg={colors.error}>{warningCount} warning{warningCount === 1 ? "" : "s"}</span>
|
|
108
|
+
</>
|
|
109
|
+
) : null}
|
|
110
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
111
|
+
<span fg={colors.muted}>{trace.traceId}</span>
|
|
112
|
+
</TextLine>
|
|
121
113
|
</box>
|
|
122
114
|
<Divider width={paneWidth} />
|
|
123
115
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
@@ -143,11 +135,11 @@ export const TraceDetailsPane = ({
|
|
|
143
135
|
<span fg={colors.muted}>{dateStr}</span>
|
|
144
136
|
</TextLine>
|
|
145
137
|
<TextLine>
|
|
146
|
-
<span fg={colors.defaultService}>{traceMeta.serviceName}</span>
|
|
147
|
-
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
148
138
|
<span fg={colors.count}>{traceMeta.spanCount} spans</span>
|
|
149
139
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
150
140
|
<span fg={colors.count}>warming adjacent trace...</span>
|
|
141
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
142
|
+
<span fg={colors.muted}>{traceMeta.traceId}</span>
|
|
151
143
|
</TextLine>
|
|
152
144
|
</box>
|
|
153
145
|
<Divider width={paneWidth} />
|
package/src/ui/TraceList.tsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import {
|
|
3
|
-
import { config } from "../config.ts"
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react"
|
|
4
3
|
import type { TraceSummaryItem } from "../domain.ts"
|
|
5
4
|
import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
|
|
6
|
-
import { PlainLine, TextLine } from "./primitives.tsx"
|
|
5
|
+
import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
|
|
7
6
|
import type { LoadStatus } from "./state.ts"
|
|
8
7
|
import { colors } from "./theme.ts"
|
|
9
8
|
|
|
@@ -12,9 +11,11 @@ const getTraceRowLayout = (contentWidth: number) => {
|
|
|
12
11
|
const durationWidth = 8
|
|
13
12
|
const countWidth = 6
|
|
14
13
|
const ageWidth = 4
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
14
|
+
// Row layout: state + gap + title + duration + gap + count + gap + age.
|
|
15
|
+
// Let the title expand to fill whatever width is left so the metrics
|
|
16
|
+
// cluster lands against the right edge of the pane.
|
|
17
|
+
const fixed = stateWidth + durationWidth + countWidth + ageWidth + 3
|
|
18
|
+
const titleWidth = Math.max(8, contentWidth - fixed)
|
|
18
19
|
return { stateWidth, durationWidth, countWidth, ageWidth, titleWidth }
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -40,13 +41,17 @@ const TraceRow = ({
|
|
|
40
41
|
: trace.rootOperationName
|
|
41
42
|
const titleColor = selected ? colors.selectedText : trace.isRunning ? colors.warning : colors.text
|
|
42
43
|
|
|
44
|
+
// Always surface a duration, including `0ms` for sub-millisecond traces —
|
|
45
|
+
// a visible duration is easier to scan than a blank column.
|
|
46
|
+
const durationText = formatDuration(Math.max(0, trace.durationMs))
|
|
47
|
+
|
|
43
48
|
return (
|
|
44
49
|
<box id={traceRowId(trace.traceId)} height={1} onMouseDown={onSelect}>
|
|
45
50
|
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
46
51
|
<span fg={traceIndicatorColor(trace)}>{fitCell(traceIndicator(trace), stateWidth)}</span>
|
|
47
52
|
<span> </span>
|
|
48
53
|
<span fg={titleColor}>{fitTraceTitle(title, titleWidth)}</span>
|
|
49
|
-
<span fg={
|
|
54
|
+
<span fg={colors.muted}>{fitCell(durationText, durationWidth, "right")}</span>
|
|
50
55
|
<span> </span>
|
|
51
56
|
<span fg={colors.muted}>{fitCell(`${trace.spanCount}sp`, countWidth, "right")}</span>
|
|
52
57
|
<span> </span>
|
|
@@ -71,8 +76,53 @@ export interface TraceListProps {
|
|
|
71
76
|
readonly onSelectTrace: (traceId: string) => void
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
interface TraceListBodyProps extends TraceListProps {
|
|
80
|
+
readonly viewportRows: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Header strip that sits above the body (renders the `TRACES 100 · filter: x`
|
|
85
|
+
* line). Kept as a separate component so the body can live inside a
|
|
86
|
+
* virtual-windowed box without the header scrolling with it.
|
|
87
|
+
*/
|
|
88
|
+
export const TraceListHeader = ({
|
|
89
|
+
traces,
|
|
90
|
+
services,
|
|
91
|
+
selectedService,
|
|
92
|
+
filterText,
|
|
93
|
+
sortMode,
|
|
94
|
+
totalCount,
|
|
95
|
+
contentWidth,
|
|
96
|
+
}: TraceListProps) => {
|
|
97
|
+
const countLabel = totalCount !== undefined && totalCount !== traces.length ? `${traces.length}/${totalCount}` : traces.length > 0 ? String(traces.length) : ""
|
|
98
|
+
const metaLabel = [
|
|
99
|
+
filterText ? `filter: ${filterText}` : null,
|
|
100
|
+
sortMode && sortMode !== "recent" ? `sort: ${sortMode}` : null,
|
|
101
|
+
].filter((part): part is string => part !== null).join(" · ")
|
|
102
|
+
const serviceLabel = services.length > 1 && selectedService ? `${services.length} services` : ""
|
|
103
|
+
const leftLabel = `TRACES${countLabel ? ` ${countLabel}` : ""}${metaLabel ? ` · ${metaLabel}` : ""}`
|
|
104
|
+
const gap = Math.max(2, contentWidth - leftLabel.length - serviceLabel.length)
|
|
105
|
+
return (
|
|
106
|
+
<TextLine>
|
|
107
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>TRACES</span>
|
|
108
|
+
{countLabel ? <span fg={colors.muted}>{` ${countLabel}`}</span> : null}
|
|
109
|
+
{metaLabel ? <span fg={colors.muted}>{` · ${metaLabel}`}</span> : null}
|
|
110
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
111
|
+
<span fg={colors.muted}>{serviceLabel}</span>
|
|
112
|
+
</TextLine>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Virtual-windowed body for the trace list. Replaces the previous
|
|
118
|
+
* opentui <scrollbox> which had a race with opentui's render-time Yoga
|
|
119
|
+
* layout: useLayoutEffect fires BEFORE the scrollbar's scrollSize has
|
|
120
|
+
* been updated to reflect new content height, so setting scrollTop after
|
|
121
|
+
* a refresh got clamped against the stale max. We own the scroll offset
|
|
122
|
+
* directly as React state and render only the visible rows, eliminating
|
|
123
|
+
* the race entirely.
|
|
124
|
+
*/
|
|
125
|
+
export const TraceListBody = ({
|
|
76
126
|
traces,
|
|
77
127
|
selectedTraceId,
|
|
78
128
|
status,
|
|
@@ -80,41 +130,103 @@ export const TraceList = ({
|
|
|
80
130
|
contentWidth,
|
|
81
131
|
services,
|
|
82
132
|
selectedService,
|
|
83
|
-
|
|
84
|
-
filterText,
|
|
85
|
-
sortMode,
|
|
86
|
-
totalCount,
|
|
133
|
+
viewportRows,
|
|
87
134
|
onSelectTrace,
|
|
88
|
-
}:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
135
|
+
}: TraceListBodyProps) => {
|
|
136
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
137
|
+
// Track (selectedTraceId, its index in `traces`) from the previous render
|
|
138
|
+
// so we can detect the refresh-shift case (same traceId, new index because
|
|
139
|
+
// rows were prepended/removed around it) and slide scrollOffset by the
|
|
140
|
+
// same delta — preserving the selected row's visual position instead of
|
|
141
|
+
// letting it jump every time auto-refresh pulls in new traces.
|
|
142
|
+
const lastSelectedIdRef = useRef<string | null>(null)
|
|
143
|
+
const lastSelectedIndexRef = useRef<number | null>(null)
|
|
144
|
+
const lastServiceRef = useRef<string | null>(null)
|
|
145
|
+
|
|
146
|
+
const viewport = Math.max(1, viewportRows)
|
|
147
|
+
const maxOffset = Math.max(0, traces.length - viewport)
|
|
148
|
+
|
|
149
|
+
useLayoutEffect(() => {
|
|
150
|
+
// Service change or initial mount: pin to top.
|
|
151
|
+
if (lastServiceRef.current !== selectedService) {
|
|
152
|
+
lastServiceRef.current = selectedService
|
|
153
|
+
lastSelectedIdRef.current = null
|
|
154
|
+
lastSelectedIndexRef.current = null
|
|
155
|
+
setScrollOffset(0)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!selectedTraceId) {
|
|
160
|
+
lastSelectedIdRef.current = null
|
|
161
|
+
lastSelectedIndexRef.current = null
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const index = traces.findIndex((t) => t.traceId === selectedTraceId)
|
|
166
|
+
if (index < 0) {
|
|
167
|
+
lastSelectedIdRef.current = null
|
|
168
|
+
lastSelectedIndexRef.current = null
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const prevId = lastSelectedIdRef.current
|
|
173
|
+
const prevIndex = lastSelectedIndexRef.current
|
|
174
|
+
const isRefreshShift = prevId === selectedTraceId && prevIndex !== null && prevIndex !== index
|
|
175
|
+
|
|
176
|
+
setScrollOffset((current) => {
|
|
177
|
+
let next = current
|
|
178
|
+
if (isRefreshShift) {
|
|
179
|
+
// Same row, new position because rows shifted around it — slide
|
|
180
|
+
// the window by the same delta to keep the row in the same
|
|
181
|
+
// visible slot.
|
|
182
|
+
next = current + (index - prevIndex)
|
|
183
|
+
} else if (index < current) {
|
|
184
|
+
// Selection moved above the viewport (user pressed k/up or
|
|
185
|
+
// jumped via gg/home). Snap the top to the selection.
|
|
186
|
+
next = index
|
|
187
|
+
} else if (index >= current + viewport) {
|
|
188
|
+
// Selection moved below the viewport — snap the bottom to it.
|
|
189
|
+
next = index - viewport + 1
|
|
190
|
+
}
|
|
191
|
+
return Math.max(0, Math.min(next, maxOffset))
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
lastSelectedIdRef.current = selectedTraceId
|
|
195
|
+
lastSelectedIndexRef.current = index
|
|
196
|
+
}, [traces, selectedTraceId, selectedService, viewport, maxOffset])
|
|
197
|
+
|
|
198
|
+
// Mouse wheel moves the scroll window WITHOUT touching selection — lets
|
|
199
|
+
// the user browse ahead of / behind their selected trace freely.
|
|
200
|
+
const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
|
|
201
|
+
const info = event.scroll
|
|
202
|
+
if (!info || traces.length === 0) return
|
|
203
|
+
const magnitude = Math.max(1, Math.round(info.delta))
|
|
204
|
+
const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
|
|
205
|
+
if (signed === 0) return
|
|
206
|
+
setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
|
|
207
|
+
event.stopPropagation?.()
|
|
109
208
|
}
|
|
110
209
|
|
|
210
|
+
if (status === "loading" && traces.length === 0) {
|
|
211
|
+
return <PlainLine text="Loading traces..." fg={colors.muted} />
|
|
212
|
+
}
|
|
213
|
+
if (status === "error") {
|
|
214
|
+
return <PlainLine text={error ?? "Could not load traces."} fg={colors.error} />
|
|
215
|
+
}
|
|
216
|
+
if (status === "ready" && services.length === 0) {
|
|
217
|
+
return <PlainLine text="No services reporting yet. Start your app and emit a span." fg={colors.muted} />
|
|
218
|
+
}
|
|
219
|
+
if (status === "ready" && selectedService && traces.length === 0) {
|
|
220
|
+
return <PlainLine text="No traces in the current lookback window." fg={colors.muted} />
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
|
|
224
|
+
const windowTraces = traces.slice(windowStart, windowStart + viewport)
|
|
225
|
+
const blanks = Math.max(0, viewport - windowTraces.length)
|
|
226
|
+
|
|
111
227
|
return (
|
|
112
|
-
<box flexDirection="column">
|
|
113
|
-
{
|
|
114
|
-
{status === "error" ? <PlainLine text={error ?? "Could not load traces."} fg={colors.error} /> : null}
|
|
115
|
-
{status === "ready" && services.length === 0 ? <PlainLine text="No services reporting yet. Start your app and emit a span." fg={colors.muted} /> : null}
|
|
116
|
-
{status === "ready" && selectedService && traces.length === 0 ? <PlainLine text="No traces in the current lookback window." fg={colors.muted} /> : null}
|
|
117
|
-
{traces.map((trace) => (
|
|
228
|
+
<box flexDirection="column" onMouseScroll={handleWheel}>
|
|
229
|
+
{windowTraces.map((trace) => (
|
|
118
230
|
<TraceRow
|
|
119
231
|
key={trace.traceId}
|
|
120
232
|
trace={trace}
|
|
@@ -123,6 +235,20 @@ export const TraceList = ({
|
|
|
123
235
|
onSelect={() => onSelectTrace(trace.traceId)}
|
|
124
236
|
/>
|
|
125
237
|
))}
|
|
238
|
+
{Array.from({ length: blanks }, (_, i) => (
|
|
239
|
+
<BlankRow key={`trace-blank-${i}`} />
|
|
240
|
+
))}
|
|
126
241
|
</box>
|
|
127
242
|
)
|
|
128
243
|
}
|
|
244
|
+
|
|
245
|
+
// Backwards-compatible single-entry wrapper (header + body) for callers
|
|
246
|
+
// that haven't been updated to the split layout yet.
|
|
247
|
+
export const TraceList = ({
|
|
248
|
+
showHeader,
|
|
249
|
+
viewportRows,
|
|
250
|
+
...props
|
|
251
|
+
}: { showHeader: boolean; viewportRows?: number } & TraceListProps) => {
|
|
252
|
+
if (showHeader) return <TraceListHeader {...props} />
|
|
253
|
+
return <TraceListBody {...props} viewportRows={viewportRows ?? 20} />
|
|
254
|
+
}
|
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { memo, useRef } from "react"
|
|
1
|
+
import { memo, useLayoutEffect, useRef, useState } from "react"
|
|
2
2
|
import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
|
|
3
|
-
import { formatDuration, lifecycleLabel, truncateText } from "./format.ts"
|
|
3
|
+
import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
|
|
4
4
|
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
5
5
|
import { colors, waterfallColors } from "./theme.ts"
|
|
6
6
|
|
|
@@ -92,12 +92,17 @@ const renderWaterfallBar = (
|
|
|
92
92
|
barWidth: number,
|
|
93
93
|
barColor: string,
|
|
94
94
|
laneColor: string,
|
|
95
|
-
|
|
95
|
+
rowBg: string,
|
|
96
|
+
): { readonly segments: readonly WaterfallBarSegment[] } => {
|
|
97
|
+
// Timeline semantics: the leading gap (before the bar starts) is the
|
|
98
|
+
// "runway" showing how long after trace start this span kicked in — render
|
|
99
|
+
// it in the lane color. The trailing gap (after the bar ends) is post-span
|
|
100
|
+
// dead time — render it in the row bg so it visually disappears.
|
|
96
101
|
if (barWidth < 3 || trace.durationMs === 0) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
102
|
+
const trailing = Math.max(0, barWidth - 1)
|
|
103
|
+
const segs: WaterfallBarSegment[] = [{ text: "\u2588", fg: barColor }]
|
|
104
|
+
if (trailing > 0) segs.push({ text: " ".repeat(trailing), fg: rowBg, bg: rowBg })
|
|
105
|
+
return { segments: segs }
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
const traceStart = trace.startedAt.getTime()
|
|
@@ -114,33 +119,40 @@ const renderWaterfallBar = (
|
|
|
114
119
|
const endOffset = endUnits % 8
|
|
115
120
|
const segments: WaterfallBarSegment[] = []
|
|
116
121
|
|
|
117
|
-
|
|
118
|
-
segments.push({ text: " ".repeat(
|
|
122
|
+
const pushLeading = (cells: number) => {
|
|
123
|
+
if (cells > 0) segments.push({ text: " ".repeat(cells), fg: laneColor, bg: laneColor })
|
|
124
|
+
}
|
|
125
|
+
const pushTrailing = (cells: number) => {
|
|
126
|
+
if (cells > 0) segments.push({ text: " ".repeat(cells), fg: rowBg, bg: rowBg })
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
pushLeading(startCell)
|
|
130
|
+
|
|
121
131
|
if (startCell === endCell) {
|
|
122
132
|
const singleCellUnits = Math.max(1, endUnits - startUnits)
|
|
123
133
|
if (singleCellUnits <= 4) {
|
|
124
134
|
const centeredMarker = ULTRA_SHORT_MARKERS[Math.max(0, singleCellUnits - 1)] ?? "\u258f"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
// The marker is a left-aligned sliver — the rest of the cell is
|
|
136
|
+
// post-bar space, so it uses the row bg (transparent) rather than
|
|
137
|
+
// carrying the dark lane track past where the span ended.
|
|
138
|
+
segments.push({ text: centeredMarker, fg: barColor, bg: rowBg })
|
|
139
|
+
pushTrailing(Math.max(0, barWidth - startCell - 1))
|
|
140
|
+
return { segments }
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
if (startOffset === 0) {
|
|
133
|
-
|
|
144
|
+
// Bar fills from the left of the cell; post-bar pixels fall to row bg.
|
|
145
|
+
segments.push({ text: PARTIAL_BLOCKS[singleCellUnits], fg: barColor, bg: rowBg })
|
|
134
146
|
} else {
|
|
147
|
+
// Bar starts partway into the cell; left pixels are lane, right is bar.
|
|
135
148
|
segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
|
|
136
149
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
afterCells: Math.max(0, barWidth - startCell - 1),
|
|
140
|
-
}
|
|
150
|
+
pushTrailing(Math.max(0, barWidth - startCell - 1))
|
|
151
|
+
return { segments }
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
if (startOffset > 0) {
|
|
155
|
+
// Leading partial: left portion is lane (runway), right is bar.
|
|
144
156
|
segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
|
|
145
157
|
}
|
|
146
158
|
|
|
@@ -152,13 +164,12 @@ const renderWaterfallBar = (
|
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
if (endOffset > 0) {
|
|
155
|
-
|
|
167
|
+
// Trailing partial: left portion is bar, right is row bg (transparent).
|
|
168
|
+
segments.push({ text: PARTIAL_BLOCKS[endOffset], fg: barColor, bg: rowBg })
|
|
156
169
|
}
|
|
157
170
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
afterCells: Math.max(0, barWidth - endCell - 1),
|
|
161
|
-
}
|
|
171
|
+
pushTrailing(Math.max(0, barWidth - endCell - 1))
|
|
172
|
+
return { segments }
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
const durationColor = (durationMs: number) => {
|
|
@@ -169,20 +180,42 @@ const durationColor = (durationMs: number) => {
|
|
|
169
180
|
return colors.muted
|
|
170
181
|
}
|
|
171
182
|
|
|
172
|
-
export const getWaterfallLayout = (contentWidth: number,
|
|
183
|
+
export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
|
|
173
184
|
const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
return { labelMaxWidth, durationWidth, logWidth, barWidth } as const
|
|
185
|
+
// Two single-space gaps: one between label and bar, one between bar and suffix.
|
|
186
|
+
const barWidth = Math.max(6, contentWidth - labelMaxWidth - suffixWidth - 2)
|
|
187
|
+
return { labelMaxWidth, barWidth } as const
|
|
178
188
|
}
|
|
179
189
|
|
|
180
|
-
export
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
190
|
+
export type WaterfallSuffixMetrics = {
|
|
191
|
+
readonly maxDurationWidth: number
|
|
192
|
+
readonly suffixWidth: number
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Compute a shared suffix (duration) width from the visible viewport.
|
|
197
|
+
* Reserving the width once keeps every row's duration right-aligned on the
|
|
198
|
+
* same column regardless of per-row content. Log correlation lives in the
|
|
199
|
+
* span detail pane, not the row suffix.
|
|
200
|
+
*/
|
|
201
|
+
export const getWaterfallSuffixMetrics = (
|
|
202
|
+
spans: readonly { readonly durationMs: number; readonly spanId: string }[],
|
|
203
|
+
): WaterfallSuffixMetrics => {
|
|
204
|
+
let maxDurationWidth = 0
|
|
205
|
+
for (const span of spans) {
|
|
206
|
+
const d = formatDuration(Math.max(0, span.durationMs)).length
|
|
207
|
+
if (d > maxDurationWidth) maxDurationWidth = d
|
|
208
|
+
}
|
|
209
|
+
return { maxDurationWidth, suffixWidth: maxDurationWidth }
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Retained for tests: per-row view of the shared layout.
|
|
213
|
+
export const getWaterfallColumns = (
|
|
214
|
+
contentWidth: number,
|
|
215
|
+
metrics: WaterfallSuffixMetrics,
|
|
216
|
+
) => {
|
|
217
|
+
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
|
|
218
|
+
return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
|
|
186
219
|
}
|
|
187
220
|
|
|
188
221
|
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
@@ -210,7 +243,6 @@ export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[]
|
|
|
210
243
|
|
|
211
244
|
const WaterfallRow = memo(({
|
|
212
245
|
span,
|
|
213
|
-
logCount,
|
|
214
246
|
trace,
|
|
215
247
|
index,
|
|
216
248
|
spans,
|
|
@@ -218,10 +250,10 @@ const WaterfallRow = memo(({
|
|
|
218
250
|
selected,
|
|
219
251
|
collapsed,
|
|
220
252
|
hasChildSpans,
|
|
253
|
+
suffixMetrics,
|
|
221
254
|
onSelect,
|
|
222
255
|
}: {
|
|
223
256
|
span: TraceSpanItem
|
|
224
|
-
logCount: number
|
|
225
257
|
trace: TraceItem
|
|
226
258
|
index: number
|
|
227
259
|
spans: readonly TraceSpanItem[]
|
|
@@ -229,6 +261,7 @@ const WaterfallRow = memo(({
|
|
|
229
261
|
selected: boolean
|
|
230
262
|
collapsed: boolean
|
|
231
263
|
hasChildSpans: boolean
|
|
264
|
+
suffixMetrics: WaterfallSuffixMetrics
|
|
232
265
|
onSelect: () => void
|
|
233
266
|
}) => {
|
|
234
267
|
const prefix = buildTreePrefix(spans, index)
|
|
@@ -236,7 +269,7 @@ const WaterfallRow = memo(({
|
|
|
236
269
|
const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
|
|
237
270
|
const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
|
|
238
271
|
|
|
239
|
-
const { labelMaxWidth, barWidth
|
|
272
|
+
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
|
|
240
273
|
|
|
241
274
|
const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
|
|
242
275
|
const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
|
|
@@ -246,14 +279,20 @@ const WaterfallRow = memo(({
|
|
|
246
279
|
const isError = span.status === "error"
|
|
247
280
|
const barColor = selected ? (isError ? waterfallColors.barSelectedError : waterfallColors.barSelected) : isError ? waterfallColors.barError : waterfallColors.bar
|
|
248
281
|
const laneColor = selected ? waterfallColors.barLane : waterfallColors.barBg
|
|
249
|
-
const
|
|
282
|
+
const rowBg = selected ? colors.selectedBg : colors.screenBg
|
|
283
|
+
const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
|
|
250
284
|
const bg = selected ? colors.selectedBg : undefined
|
|
251
285
|
const treeColor = selected ? colors.separator : colors.treeLine
|
|
252
286
|
const indicatorColor = isError ? colors.error : hasChildSpans ? (selected ? colors.selectedText : colors.muted) : colors.passing
|
|
253
287
|
const opColor = selected ? colors.selectedText : span.isRunning ? colors.warning : colors.text
|
|
254
288
|
|
|
255
289
|
const durationFg = durationColor(span.durationMs)
|
|
256
|
-
const
|
|
290
|
+
const unitFg = colors.muted
|
|
291
|
+
|
|
292
|
+
// Split the duration so the unit (s/ms) renders dimmer than the number.
|
|
293
|
+
const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
|
|
294
|
+
const durationCell = `${durNumber}${durUnit}`
|
|
295
|
+
const durationPad = " ".repeat(Math.max(0, suffixMetrics.maxDurationWidth - durationCell.length))
|
|
257
296
|
|
|
258
297
|
return (
|
|
259
298
|
<box height={1} onMouseDown={onSelect}>
|
|
@@ -267,8 +306,9 @@ const WaterfallRow = memo(({
|
|
|
267
306
|
<span key={`${span.spanId}-bar-${index}`} fg={segment.fg} bg={segment.bg}>{segment.text}</span>
|
|
268
307
|
))}
|
|
269
308
|
<span> </span>
|
|
270
|
-
<span
|
|
271
|
-
<span fg={
|
|
309
|
+
<span>{durationPad}</span>
|
|
310
|
+
<span fg={durationFg}>{durNumber}</span>
|
|
311
|
+
<span fg={unitFg}>{durUnit}</span>
|
|
272
312
|
</TextLine>
|
|
273
313
|
</box>
|
|
274
314
|
)
|
|
@@ -347,8 +387,6 @@ export const WaterfallTimeline = ({
|
|
|
347
387
|
}) => {
|
|
348
388
|
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
349
389
|
|
|
350
|
-
const { labelMaxWidth, durationWidth, barWidth } = getWaterfallLayout(contentWidth, trace.durationMs)
|
|
351
|
-
|
|
352
390
|
const spanIndexById = new Map<string, number>()
|
|
353
391
|
for (let i = 0; i < trace.spans.length; i++) {
|
|
354
392
|
spanIndexById.set(trace.spans[i].spanId, i)
|
|
@@ -380,8 +418,28 @@ export const WaterfallTimeline = ({
|
|
|
380
418
|
const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
|
|
381
419
|
const blankCount = Math.max(0, viewportSize - windowSpans.length)
|
|
382
420
|
|
|
421
|
+
// One shared suffix width, measured from the current viewport, so every
|
|
422
|
+
// row's duration cell lines up on the same right-edge column.
|
|
423
|
+
const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
|
|
424
|
+
|
|
425
|
+
// Mouse wheel moves the span selection by the scroll delta. The waterfall
|
|
426
|
+
// uses virtual windowing (not a scrollbox) so native scroll does nothing;
|
|
427
|
+
// we convert wheel events into selection moves, which the windowing code
|
|
428
|
+
// already translates into visible-viewport shifts.
|
|
429
|
+
const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
|
|
430
|
+
const info = event.scroll
|
|
431
|
+
if (!info || filteredSpans.length === 0) return
|
|
432
|
+
const magnitude = Math.max(1, Math.round(info.delta))
|
|
433
|
+
const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
|
|
434
|
+
if (signed === 0) return
|
|
435
|
+
const start = selectedSpanIndex ?? 0
|
|
436
|
+
const next = Math.max(0, Math.min(start + signed, filteredSpans.length - 1))
|
|
437
|
+
if (next !== selectedSpanIndex) onSelectSpan(next)
|
|
438
|
+
event.stopPropagation?.()
|
|
439
|
+
}
|
|
440
|
+
|
|
383
441
|
return (
|
|
384
|
-
<box flexDirection="column">
|
|
442
|
+
<box flexDirection="column" onMouseScroll={handleWheel}>
|
|
385
443
|
{windowSpans.map((span, index) => {
|
|
386
444
|
const actualIndex = windowStart + index
|
|
387
445
|
const fullIndex = spanIndexById.get(span.spanId) ?? -1
|
|
@@ -389,7 +447,6 @@ export const WaterfallTimeline = ({
|
|
|
389
447
|
<WaterfallRow
|
|
390
448
|
key={`${trace.traceId}-${span.spanId}`}
|
|
391
449
|
span={span}
|
|
392
|
-
logCount={spanLogCounts.get(span.spanId) ?? 0}
|
|
393
450
|
trace={trace}
|
|
394
451
|
index={fullIndex}
|
|
395
452
|
spans={trace.spans}
|
|
@@ -397,6 +454,7 @@ export const WaterfallTimeline = ({
|
|
|
397
454
|
selected={selectedSpanIndex === actualIndex}
|
|
398
455
|
collapsed={collapsedSpanIds.has(span.spanId)}
|
|
399
456
|
hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
|
|
457
|
+
suffixMetrics={suffixMetrics}
|
|
400
458
|
onSelect={() => onSelectSpan(actualIndex)}
|
|
401
459
|
/>
|
|
402
460
|
)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
-
import type { RefObject } from "react"
|
|
3
1
|
import { FilterBar } from "../primitives.tsx"
|
|
4
|
-
import {
|
|
2
|
+
import { TraceListBody, TraceListHeader, type TraceListProps } from "../TraceList.tsx"
|
|
5
3
|
|
|
6
4
|
interface TraceListPaneProps {
|
|
7
5
|
readonly traceListProps: TraceListProps
|
|
@@ -11,9 +9,16 @@ interface TraceListPaneProps {
|
|
|
11
9
|
readonly containerHeight: number
|
|
12
10
|
readonly bodyHeight: number
|
|
13
11
|
readonly padding: number
|
|
14
|
-
readonly scrollRef: RefObject<ScrollBoxRenderable | null>
|
|
15
12
|
}
|
|
16
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Replaced the opentui <scrollbox> with a direct virtual-windowed body.
|
|
16
|
+
* Rationale: the scrollbox's scrollSize is updated during opentui's render
|
|
17
|
+
* pass, not during React commit, so the useLayoutEffect that adjusted
|
|
18
|
+
* scrollTop on refresh was reading a stale max and clamping our intended
|
|
19
|
+
* scroll position. Rendering only the visible rows ourselves keeps the
|
|
20
|
+
* viewport math entirely in React state and eliminates the race.
|
|
21
|
+
*/
|
|
17
22
|
export const TraceListPane = ({
|
|
18
23
|
traceListProps,
|
|
19
24
|
filterMode,
|
|
@@ -22,13 +27,13 @@ export const TraceListPane = ({
|
|
|
22
27
|
containerHeight,
|
|
23
28
|
bodyHeight,
|
|
24
29
|
padding,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<
|
|
32
|
-
</
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
}: TraceListPaneProps) => {
|
|
31
|
+
const bodyRows = Math.max(1, filterMode ? bodyHeight - 1 : bodyHeight)
|
|
32
|
+
return (
|
|
33
|
+
<box height={containerHeight} flexDirection="column" paddingLeft={padding} paddingRight={0}>
|
|
34
|
+
<TraceListHeader {...traceListProps} />
|
|
35
|
+
{filterMode ? <FilterBar text={filterText} width={filterWidth} /> : null}
|
|
36
|
+
<TraceListBody {...traceListProps} viewportRows={bodyRows} />
|
|
37
|
+
</box>
|
|
38
|
+
)
|
|
39
|
+
}
|