@kitlangton/motel 0.1.1 → 0.1.3

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.
@@ -0,0 +1,120 @@
1
+ import { RGBA, TextAttributes } from "@opentui/core"
2
+ import { BlankRow, TextLine } from "./primitives.tsx"
3
+ import { colors } from "./theme.ts"
4
+ import { fitCell, truncateText } from "./format.ts"
5
+ import type { AttrFacetState, AttrPickerMode } from "./state.ts"
6
+
7
+ export interface AttrFilterModalProps {
8
+ readonly width: number
9
+ readonly height: number
10
+ readonly mode: Exclude<AttrPickerMode, "off">
11
+ readonly input: string
12
+ readonly selectedIndex: number
13
+ readonly selectedKey: string | null
14
+ readonly state: AttrFacetState
15
+ readonly onClose: () => void
16
+ }
17
+
18
+ // Filter + rank facet rows by the user's current input so typing is
19
+ // responsive even on large key sets. Values list skips this — attribute
20
+ // values are usually opaque ids that users paste in whole.
21
+ export const filterFacets = (
22
+ rows: readonly { readonly value: string; readonly count: number }[],
23
+ input: string,
24
+ ): readonly { readonly value: string; readonly count: number }[] => {
25
+ const needle = input.trim().toLowerCase()
26
+ if (!needle) return rows
27
+ return rows.filter((row) => row.value.toLowerCase().includes(needle))
28
+ }
29
+
30
+ export const AttrFilterModal = ({
31
+ width,
32
+ height,
33
+ mode,
34
+ input,
35
+ selectedIndex,
36
+ selectedKey,
37
+ state,
38
+ onClose,
39
+ }: AttrFilterModalProps) => {
40
+ const panelWidth = Math.min(92, Math.max(60, width - 10))
41
+ const left = Math.max(2, Math.floor((width - panelWidth) / 2))
42
+ const top = Math.max(1, Math.floor(height / 6))
43
+ const innerWidth = panelWidth - 4
44
+ const rows = filterFacets(state.data, input)
45
+ const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(selectedIndex, rows.length - 1))
46
+ const visibleRowCount = Math.max(5, Math.min(18, height - top - 8))
47
+ const windowStart = Math.max(0, clampedIndex - Math.floor(visibleRowCount / 2))
48
+ const windowEnd = Math.min(rows.length, windowStart + visibleRowCount)
49
+ const windowed = rows.slice(windowStart, windowEnd)
50
+
51
+ const title = mode === "keys"
52
+ ? "Filter traces by attribute key"
53
+ : `Filter · ${truncateText(selectedKey ?? "", innerWidth - 14)}`
54
+
55
+ const hint = mode === "keys"
56
+ ? "type to narrow · ↑↓ move · enter select · esc cancel"
57
+ : "type to narrow · ↑↓ move · enter apply · backspace keys · esc cancel"
58
+
59
+ const countWidth = 7
60
+ const valueWidth = Math.max(10, innerWidth - countWidth - 1)
61
+
62
+ const renderRow = (row: { readonly value: string; readonly count: number }, isSelected: boolean) => {
63
+ const label = fitCell(row.value, valueWidth)
64
+ const count = String(row.count).padStart(countWidth - 1) + " "
65
+ if (isSelected) {
66
+ return (
67
+ <TextLine fg={colors.text} bg={colors.selectedBg}>
68
+ <span fg={colors.accent} attributes={TextAttributes.BOLD}>{label}</span>
69
+ <span fg={colors.muted}>{" "}</span>
70
+ <span fg={colors.muted}>{count}</span>
71
+ </TextLine>
72
+ )
73
+ }
74
+ return (
75
+ <TextLine>
76
+ <span fg={colors.text}>{label}</span>
77
+ <span fg={colors.muted}>{" "}</span>
78
+ <span fg={colors.count}>{count}</span>
79
+ </TextLine>
80
+ )
81
+ }
82
+
83
+ return (
84
+ <box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
85
+ <box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
86
+ <box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
87
+ <TextLine>
88
+ <span fg={colors.count} attributes={TextAttributes.BOLD}>{truncateText(title, innerWidth)}</span>
89
+ </TextLine>
90
+ <TextLine>
91
+ <span fg={colors.muted}>{truncateText(hint, innerWidth)}</span>
92
+ </TextLine>
93
+ <BlankRow />
94
+ <TextLine fg={colors.accent}>
95
+ <span fg={colors.muted}>{"\u203a "}</span>
96
+ <span fg={colors.text}>{truncateText(input, Math.max(1, innerWidth - 4))}</span>
97
+ <span fg={colors.accent}>{"\u2588"}</span>
98
+ </TextLine>
99
+ <BlankRow />
100
+ {state.status === "loading" && rows.length === 0 ? (
101
+ <TextLine><span fg={colors.muted}>loading…</span></TextLine>
102
+ ) : state.error ? (
103
+ <TextLine><span fg={colors.error}>{truncateText(state.error, innerWidth)}</span></TextLine>
104
+ ) : rows.length === 0 ? (
105
+ <TextLine><span fg={colors.muted}>no matches</span></TextLine>
106
+ ) : (
107
+ windowed.map((row, i) => (
108
+ <box key={row.value} height={1}>
109
+ {renderRow(row, windowStart + i === clampedIndex)}
110
+ </box>
111
+ ))
112
+ )}
113
+ {rows.length > windowEnd ? (
114
+ <TextLine><span fg={colors.muted}>{`+${rows.length - windowEnd} more…`}</span></TextLine>
115
+ ) : null}
116
+ </box>
117
+ </box>
118
+ </box>
119
+ )
120
+ }
@@ -30,8 +30,7 @@ export const SpanDetailPane = ({
30
30
  paneWidth: number
31
31
  focused?: boolean
32
32
  }) => {
33
- const focusIndicator = focused ? "\u25b8 " : ""
34
- const headerTitle = `${focusIndicator}SPAN`
33
+ const headerTitle = "SPAN"
35
34
  const headerRight = span
36
35
  ? `${span.status} \u00b7 ${formatDuration(span.durationMs)}${logs.length > 0 ? ` \u00b7 ${logs.length} lg` : ""}`
37
36
  : "no span selected"
@@ -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 focusIndicator = focused ? "\u25b8 " : ""
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
- {warningCount > 0 ? (
105
- <TextLine>
106
- <span fg={colors.defaultService}>{trace.serviceName}</span>
107
- <span fg={colors.separator}>{SEPARATOR}</span>
108
- <span fg={colors.count}>{trace.spanCount} spans</span>
109
- <span fg={colors.separator}>{SEPARATOR}</span>
110
- <span fg={colors.error}>{warningCount} warning{warningCount === 1 ? "" : "s"}: {firstWarning}</span>
111
- </TextLine>
112
- ) : (
113
- <TextLine>
114
- <span fg={colors.defaultService}>{trace.serviceName}</span>
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} />
@@ -1,9 +1,8 @@
1
1
  import { TextAttributes } from "@opentui/core"
2
- import { memo } from "react"
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
- // Keep the operation column intentionally capped so the metrics cluster stays
16
- // visually closer, but still allow common operation names to fit cleanly.
17
- const titleWidth = Math.min(20, Math.max(8, contentWidth - stateWidth - durationWidth - countWidth - ageWidth - 2))
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={selected ? colors.accent : colors.count}>{fitCell(trace.durationMs >= 1 ? formatDuration(trace.durationMs) : "", durationWidth, "right")}</span>
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
- export const TraceList = ({
75
- showHeader,
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
- focused = true,
84
- filterText,
85
- sortMode,
86
- totalCount,
133
+ viewportRows,
87
134
  onSelectTrace,
88
- }: { showHeader: boolean } & TraceListProps) => {
89
- if (showHeader) {
90
- const countLabel = totalCount !== undefined && totalCount !== traces.length ? `${traces.length}/${totalCount}` : traces.length > 0 ? String(traces.length) : ""
91
- const metaLabel = [
92
- filterText ? `filter: ${filterText}` : null,
93
- sortMode && sortMode !== "recent" ? `sort: ${sortMode}` : null,
94
- ].filter((part): part is string => part !== null).join(" · ")
95
- const serviceLabel = services.length > 1 && selectedService
96
- ? `${services.length} services`
97
- : ""
98
- const leftLabel = `TRACES${countLabel ? ` ${countLabel}` : ""}${metaLabel ? ` · ${metaLabel}` : ""}`
99
- const gap = Math.max(2, contentWidth - leftLabel.length - serviceLabel.length)
100
- return (
101
- <TextLine>
102
- <span fg={colors.accent} attributes={TextAttributes.BOLD}>TRACES</span>
103
- {countLabel ? <span fg={colors.muted}>{` ${countLabel}`}</span> : null}
104
- {metaLabel ? <span fg={colors.muted}>{` · ${metaLabel}`}</span> : null}
105
- <span fg={colors.muted}>{" ".repeat(gap)}</span>
106
- <span fg={colors.muted}>{serviceLabel}</span>
107
- </TextLine>
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
- {status === "loading" && traces.length === 0 ? <PlainLine text="Loading traces..." fg={colors.muted} /> : null}
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
+ }