@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.
@@ -1,5 +1,3 @@
1
- import type { ScrollBoxRenderable } from "@opentui/core"
2
- import type { RefObject } from "react"
3
1
  import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
4
2
  import { formatShortDate, formatTimestamp } from "../format.ts"
5
3
  import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from "../primitives.tsx"
@@ -21,7 +19,6 @@ interface TraceWorkspaceProps {
21
19
  readonly filterMode: boolean
22
20
  readonly filterText: string
23
21
  readonly traceListProps: TraceListProps
24
- readonly traceListScrollRef: RefObject<ScrollBoxRenderable | null>
25
22
  readonly selectedTraceService: string | null
26
23
  readonly serviceLogState: ServiceLogState
27
24
  readonly selectedServiceLogIndex: number
@@ -44,7 +41,6 @@ export const TraceWorkspace = ({
44
41
  filterMode,
45
42
  filterText,
46
43
  traceListProps,
47
- traceListScrollRef,
48
44
  selectedTraceService,
49
45
  serviceLogState,
50
46
  selectedServiceLogIndex,
@@ -107,10 +103,11 @@ export const TraceWorkspace = ({
107
103
  }
108
104
 
109
105
  if (isWideLayout) {
110
- return (
111
- <box flexGrow={1} flexDirection="row">
112
- <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
113
- {viewLevel <= 1 ? (
106
+ // L0: list (left) + trace preview (right). The two-pane zoom.
107
+ if (viewLevel === 0) {
108
+ return (
109
+ <box flexGrow={1} flexDirection="row">
110
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
114
111
  <TraceListPane
115
112
  traceListProps={traceListProps}
116
113
  filterMode={filterMode}
@@ -119,53 +116,86 @@ export const TraceWorkspace = ({
119
116
  containerHeight={wideBodyHeight}
120
117
  bodyHeight={wideTraceListBodyHeight}
121
118
  padding={sectionPadding}
122
- scrollRef={traceListScrollRef}
123
119
  />
124
- ) : (
120
+ </box>
121
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorJunctionChars} />
122
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
125
123
  <TraceDetailsPane
126
124
  trace={selectedTrace}
127
125
  traceSummary={selectedTraceSummary}
128
126
  traceStatus={traceDetailState.status}
129
127
  traceError={traceDetailState.error}
130
128
  traceLogsState={logState}
131
- contentWidth={leftContentWidth}
129
+ contentWidth={rightContentWidth}
132
130
  bodyLines={wideBodyLines}
133
- paneWidth={leftPaneWidth}
131
+ paneWidth={rightPaneWidth}
134
132
  selectedSpanIndex={selectedSpanIndex}
135
133
  collapsedSpanIds={collapsedSpanIds}
136
134
  focused={false}
137
135
  onSelectSpan={selectSpan}
138
136
  />
139
- )}
137
+ </box>
140
138
  </box>
141
- <SeparatorColumn height={wideBodyHeight} junctionChars={viewLevel === 2 ? separatorCrossChars : separatorJunctionChars} />
142
- <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
143
- {viewLevel <= 1 ? (
139
+ )
140
+ }
141
+
142
+ // L1: the user pressed enter on a trace — hide the list entirely and
143
+ // let the waterfall take the full width. `leftPaneWidth` is already
144
+ // `contentWidth` in this case (see useAppLayout), so one pane fills
145
+ // the row.
146
+ if (viewLevel === 1) {
147
+ return (
148
+ <box flexGrow={1} flexDirection="row">
149
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
144
150
  <TraceDetailsPane
145
151
  trace={selectedTrace}
146
152
  traceSummary={selectedTraceSummary}
147
153
  traceStatus={traceDetailState.status}
148
154
  traceError={traceDetailState.error}
149
155
  traceLogsState={logState}
150
- contentWidth={rightContentWidth}
156
+ contentWidth={Math.max(24, leftPaneWidth - sectionPadding * 2)}
151
157
  bodyLines={wideBodyLines}
152
- paneWidth={rightPaneWidth}
158
+ paneWidth={leftPaneWidth}
153
159
  selectedSpanIndex={selectedSpanIndex}
154
160
  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
161
  focused={true}
162
+ onSelectSpan={selectSpan}
167
163
  />
168
- )}
164
+ </box>
165
+ </box>
166
+ )
167
+ }
168
+
169
+ // L2: waterfall-left + span detail-right. Still no list.
170
+ return (
171
+ <box flexGrow={1} flexDirection="row">
172
+ <box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
173
+ <TraceDetailsPane
174
+ trace={selectedTrace}
175
+ traceSummary={selectedTraceSummary}
176
+ traceStatus={traceDetailState.status}
177
+ traceError={traceDetailState.error}
178
+ traceLogsState={logState}
179
+ contentWidth={leftContentWidth}
180
+ bodyLines={wideBodyLines}
181
+ paneWidth={leftPaneWidth}
182
+ selectedSpanIndex={selectedSpanIndex}
183
+ collapsedSpanIds={collapsedSpanIds}
184
+ focused={false}
185
+ onSelectSpan={selectSpan}
186
+ />
187
+ </box>
188
+ <SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
189
+ <box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
190
+ <SpanDetailPane
191
+ span={selectedSpan}
192
+ trace={selectedTrace}
193
+ logs={selectedSpanLogs}
194
+ contentWidth={rightContentWidth}
195
+ bodyLines={wideBodyLines}
196
+ paneWidth={rightPaneWidth}
197
+ focused={true}
198
+ />
169
199
  </box>
170
200
  </box>
171
201
  )
@@ -182,7 +212,6 @@ export const TraceWorkspace = ({
182
212
  containerHeight={narrowListHeight}
183
213
  bodyHeight={narrowTraceListBodyHeight}
184
214
  padding={sectionPadding}
185
- scrollRef={traceListScrollRef}
186
215
  />
187
216
  <Divider width={contentWidth} />
188
217
  <TraceDetailsPane
@@ -25,10 +25,28 @@ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIn
25
25
  detailView === "span-detail" ? 2 :
26
26
  selectedSpanIndex !== null ? 1 :
27
27
  0
28
+ // At L0 we show list + preview side-by-side. Once drilled in (L1/L2) the
29
+ // trace list is hidden entirely and the detail pane(s) take the full
30
+ // width — either one pane (waterfall at L1) or a 50/50 split between
31
+ // waterfall and span detail at L2.
28
32
  const splitRatio = viewLevelForLayout === 2 ? 0.5 : 0.4
29
- const leftPaneWidth = isWideLayout ? Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio)) : contentWidth
30
- const rightPaneWidth = isWideLayout ? Math.max(28, contentWidth - leftPaneWidth - splitGap) : contentWidth
31
- const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 3) : Math.max(24, contentWidth - sectionPadding * 2)
33
+ const listHidden = viewLevelForLayout >= 1
34
+ const leftPaneWidth = !isWideLayout
35
+ ? contentWidth
36
+ : listHidden
37
+ ? (viewLevelForLayout === 2 ? Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio)) : contentWidth)
38
+ : Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio))
39
+ const rightPaneWidth = !isWideLayout
40
+ ? contentWidth
41
+ : listHidden && viewLevelForLayout !== 2
42
+ ? 0
43
+ : Math.max(28, contentWidth - leftPaneWidth - splitGap)
44
+ // Left pane: paddingLeft (1) + scrollbar column (1). No right padding —
45
+ // the vertical pane divider handles visual separation from the right pane.
46
+ const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)
47
+ // Right pane: both left and right padding. Trace details and span detail
48
+ // content needs a little breathing room on the right so long op names
49
+ // and the duration column don't butt against the pane border.
32
50
  const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
33
51
  const headerFooterWidth = Math.max(24, contentWidth - 2)
34
52
  const wideBodyHeight = availableContentHeight
@@ -56,6 +74,7 @@ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIn
56
74
  sectionPadding,
57
75
  availableContentHeight,
58
76
  viewLevel: viewLevelForLayout,
77
+ listHidden,
59
78
  footerNotice,
60
79
  footerHeight,
61
80
  leftPaneWidth,
@@ -3,6 +3,8 @@ import { useCallback, useEffect, useMemo, useRef } from "react"
3
3
  import { config } from "../../config.js"
4
4
  import type { LogItem, TraceItem } from "../../domain.ts"
5
5
  import {
6
+ activeAttrKeyAtom,
7
+ activeAttrValueAtom,
6
8
  autoRefreshAtom,
7
9
  collapsedSpanIdsAtom,
8
10
  detailViewAtom,
@@ -11,6 +13,7 @@ import {
11
13
  initialLogState,
12
14
  initialServiceLogState,
13
15
  initialTraceDetailState,
16
+ loadFilteredTraceSummaries,
14
17
  loadRecentTraceSummaries,
15
18
  loadServiceLogs,
16
19
  loadTraceDetail,
@@ -47,6 +50,8 @@ export const useTraceScreenData = () => {
47
50
  const [autoRefresh] = useAtom(autoRefreshAtom)
48
51
  const [filterMode] = useAtom(filterModeAtom)
49
52
  const [filterText] = useAtom(filterTextAtom)
53
+ const [activeAttrKey] = useAtom(activeAttrKeyAtom)
54
+ const [activeAttrValue] = useAtom(activeAttrValueAtom)
50
55
  const [traceSort] = useAtom(traceSortAtom)
51
56
 
52
57
  const selectedTraceRef = useRef<string | null>(null)
@@ -94,7 +99,11 @@ export const useTraceScreenData = () => {
94
99
  setSelectedTraceService(effectiveService)
95
100
  }
96
101
 
97
- const traces = effectiveService ? await loadRecentTraceSummaries(effectiveService) : []
102
+ const traces = effectiveService
103
+ ? (activeAttrKey && activeAttrValue
104
+ ? await loadFilteredTraceSummaries(effectiveService, { [activeAttrKey]: activeAttrValue })
105
+ : await loadRecentTraceSummaries(effectiveService))
106
+ : []
98
107
  if (cancelled) return
99
108
 
100
109
  const prevTraceId = selectedTraceRef.current
@@ -117,7 +126,7 @@ export const useTraceScreenData = () => {
117
126
  return () => {
118
127
  cancelled = true
119
128
  }
120
- }, [refreshNonce, selectedTraceService, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
129
+ }, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
121
130
 
122
131
  useEffect(() => {
123
132
  setSelectedTraceIndex((current) => {
@@ -402,6 +411,8 @@ export const useTraceScreenData = () => {
402
411
  autoRefresh,
403
412
  filterMode,
404
413
  filterText,
414
+ activeAttrKey,
415
+ activeAttrValue,
405
416
  traceSort,
406
417
  selectedTraceSummary,
407
418
  selectedTrace,
package/src/ui/format.ts CHANGED
@@ -19,13 +19,22 @@ export const formatShortDate = (date: Date) => date.toLocaleDateString("en-US",
19
19
  export const formatTimestamp = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase()
20
20
 
21
21
  export const formatDuration = (durationMs: number) => {
22
+ const { number, unit } = splitDuration(durationMs)
23
+ return `${number}${unit}`
24
+ }
25
+
26
+ /**
27
+ * Split a duration into its numeric and unit parts so the unit can render
28
+ * dimmer than the number (easier to visually parse a column of durations).
29
+ */
30
+ export const splitDuration = (durationMs: number): { number: string; unit: "s" | "ms" } => {
22
31
  const trimDecimal = (value: string) => value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")
23
32
 
24
- if (durationMs >= 10_000) return `${Math.round(durationMs / 1000)}s`
25
- if (durationMs >= 1000) return `${trimDecimal((durationMs / 1000).toFixed(1))}s`
26
- if (durationMs >= 100) return `${Math.round(durationMs)}ms`
27
- if (durationMs >= 10) return `${trimDecimal(durationMs.toFixed(1))}ms`
28
- return `${trimDecimal(durationMs.toFixed(2))}ms`
33
+ if (durationMs >= 10_000) return { number: `${Math.round(durationMs / 1000)}`, unit: "s" }
34
+ if (durationMs >= 1000) return { number: trimDecimal((durationMs / 1000).toFixed(1)), unit: "s" }
35
+ if (durationMs >= 100) return { number: `${Math.round(durationMs)}`, unit: "ms" }
36
+ if (durationMs >= 10) return { number: trimDecimal(durationMs.toFixed(1)), unit: "ms" }
37
+ return { number: trimDecimal(durationMs.toFixed(2)), unit: "ms" }
29
38
  }
30
39
 
31
40
  export const lifecycleLabel = (value: { readonly isRunning: boolean }) => (value.isRunning ? "open" : "closed")
@@ -118,7 +118,8 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
118
118
  {row("t", `cycle theme (${themeLabel})`)}
119
119
  {row("tab", "toggle service logs")}
120
120
  {row("[ ]", "switch service")}
121
- {row("/", "filter traces")}
121
+ {row("/", "filter by root operation")}
122
+ {row("f", "filter traces by span attribute")}
122
123
  {row("s", "cycle sort mode")}
123
124
  {row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
124
125
  {row("r", "refresh traces")}
@@ -149,6 +150,7 @@ export const FooterHints = ({ spanNavActive, detailView, autoRefresh, width: _wi
149
150
  ["t", "theme"],
150
151
  ["tab", "logs"],
151
152
  ["/", "filter"],
153
+ ["f", "attr"],
152
154
  ["s", "sort"],
153
155
  ["a", autoRefresh ? "live" : "paused"],
154
156
  ["?", "help"],
package/src/ui/state.ts CHANGED
@@ -106,6 +106,32 @@ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
106
106
  export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
107
107
  export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
108
108
 
109
+ // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
110
+ export type AttrPickerMode = "off" | "keys" | "values"
111
+ export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
112
+ export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
113
+ export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
114
+
115
+ export interface AttrFacetState {
116
+ readonly status: LoadStatus
117
+ readonly key: string | null // null when loading keys; set when loading values
118
+ readonly data: readonly { readonly value: string; readonly count: number }[]
119
+ readonly error: string | null
120
+ }
121
+
122
+ export const initialAttrFacetState: AttrFacetState = {
123
+ status: "ready",
124
+ key: null,
125
+ data: [],
126
+ error: null,
127
+ }
128
+
129
+ export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
130
+
131
+ // Applied filter (drives trace list query)
132
+ export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
133
+ export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
134
+
109
135
  const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
110
136
  const readLastTheme = (): ThemeName => {
111
137
  try {
@@ -132,6 +158,12 @@ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<s
132
158
 
133
159
  export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
134
160
  export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
161
+ export const loadFilteredTraceSummaries = (serviceName: string, attributeFilters: Readonly<Record<string, string>>) =>
162
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({ serviceName, attributeFilters, limit: config.otel.traceFetchLimit })))
163
+ export const loadTraceAttributeKeys = (serviceName: string) =>
164
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
165
+ export const loadTraceAttributeValues = (serviceName: string, key: string) =>
166
+ queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
135
167
  export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
136
168
  export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
137
169
  export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
package/src/ui/theme.ts CHANGED
@@ -32,32 +32,37 @@ export interface ThemeDefinition {
32
32
  readonly waterfall: ThemeWaterfallColors
33
33
  }
34
34
 
35
+ // motel-default palette derived in OKLCH. All "surface" tokens share hue
36
+ // 282 (twilight purple) at varying lightness so depth is communicated by
37
+ // lightness alone (footer < screen < selected < bar track). The amber
38
+ // accent (hue 73) sits almost complementary to the surfaces, giving the
39
+ // motel-sign neon maximum contrast without color clash.
35
40
  const motelDefaultTheme: ThemeDefinition = {
36
41
  name: "motel-default",
37
42
  label: "Motel Default",
38
43
  colors: {
39
- screenBg: "#1c1b29",
40
- text: "#ede7da",
41
- muted: "#9f9788",
42
- separator: "#6f685d",
43
- accent: "#f4a51c",
44
- error: "#f97316",
45
- selectedBg: "#263044",
46
- warning: "#facc15",
44
+ screenBg: "#111120", // oklch(0.185 0.030 282)
45
+ text: "#eee5d6", // oklch(0.925 0.022 82) — warm cream
46
+ muted: "#9a9181", // oklch(0.660 0.025 82)
47
+ separator: "#686155", // oklch(0.495 0.020 81)
48
+ accent: "#f5a41a", // oklch(0.780 0.161 73) — motel neon
49
+ error: "#f97312", // oklch(0.705 0.187 48)
50
+ selectedBg: "#2b2c48", // oklch(0.305 0.050 282) — same hue as screen
51
+ warning: "#facc16", // oklch(0.861 0.173 92)
47
52
  selectedText: "#f8fafc",
48
- count: "#d7c5a1",
49
- passing: "#7dd3a3",
50
- defaultService: "#93c5fd",
51
- footerBg: "#000000",
52
- treeLine: "#524d45",
53
- previewKey: "#6a6358",
53
+ count: "#d7c5a1", // oklch(0.830 0.052 85)
54
+ passing: "#7ed5a4", // oklch(0.805 0.110 158)
55
+ defaultService: "#93c5fe", // oklch(0.810 0.096 252)
56
+ footerBg: "#04040e", // oklch(0.115 0.025 282) — deeper than screen
57
+ treeLine: "#48433b", // oklch(0.385 0.015 80)
58
+ previewKey: "#645d51", // oklch(0.480 0.020 80)
54
59
  },
55
60
  waterfall: {
56
- bar: "#f4a51c",
57
- barError: "#f97316",
58
- barBg: "#2a2520",
59
- barLane: "#4a4338",
60
- barSelected: "#e8c547",
61
+ bar: "#f5a41a", // = accent
62
+ barError: "#f97312", // = error
63
+ barBg: "#1f1f34", // oklch(0.250 0.040 282) — purple track (was warm)
64
+ barLane: "#3d3e5b", // oklch(0.375 0.050 282)
65
+ barSelected: "#f3c048", // oklch(0.832 0.145 85) — warmer amber
61
66
  barSelectedError: "#ff8c42",
62
67
  },
63
68
  }
@@ -0,0 +1,115 @@
1
+ /**
2
+ * End-to-end reproducer for waterfall underfilling the trace-details pane.
3
+ *
4
+ * Strategy:
5
+ * 1. Seed a deterministic trace into a fresh SQLite database.
6
+ * 2. Launch the motel TUI under tuistory in narrow mode so trace details take
7
+ * the full screen width.
8
+ * 3. Drill into the trace details view.
9
+ * 4. Assert the root waterfall row reaches the right-side duration column
10
+ * instead of stopping several cells early.
11
+ */
12
+
13
+ import { afterAll, beforeAll, describe, expect, it } from "bun:test"
14
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
15
+ import { tmpdir } from "node:os"
16
+ import { join } from "node:path"
17
+
18
+ const TUISTORY_BIN = "tuistory"
19
+ const SESSION = `motel-trace-width-${Date.now()}`
20
+
21
+ const hasTuistory = async () => {
22
+ try {
23
+ const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
24
+ return (await proc.exited) === 0
25
+ } catch {
26
+ return false
27
+ }
28
+ }
29
+
30
+ const tui = async (args: readonly string[]) => {
31
+ const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
32
+ const [stdout, stderr] = await Promise.all([
33
+ new Response(proc.stdout).text(),
34
+ new Response(proc.stderr).text(),
35
+ ])
36
+ return { code: await proc.exited, stdout, stderr }
37
+ }
38
+
39
+ const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
40
+
41
+ const press = async (...keys: string[]) => {
42
+ await tui(["press", "--session", SESSION, ...keys])
43
+ await Bun.sleep(120)
44
+ }
45
+
46
+ const dividerWidth = (snap: string) =>
47
+ snap.split("\n").find((line) => line.startsWith("─"))?.length ?? 0
48
+
49
+ const rootWaterfallRow = (snap: string) =>
50
+ snap.split("\n").find((line) => line.startsWith(" ▾ root.op") || line.startsWith(" ▸ root.op") || line.startsWith(" · root.op")) ?? null
51
+
52
+ describe("trace details waterfall width (end-to-end TUI)", () => {
53
+ const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
54
+ const dbPath = join(tempDir, "telemetry.sqlite")
55
+ const lastServicePath = join(tempDir, "last-service.txt")
56
+ let canRun = false
57
+
58
+ beforeAll(async () => {
59
+ canRun = await hasTuistory()
60
+ if (!canRun) return
61
+
62
+ writeFileSync(lastServicePath, "waterfall-repro")
63
+
64
+ const seed = Bun.spawn({
65
+ cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
66
+ cwd: process.cwd(),
67
+ env: {
68
+ ...process.env,
69
+ MOTEL_OTEL_DB_PATH: dbPath,
70
+ MOTEL_OTEL_ENABLED: "false",
71
+ },
72
+ stdout: "pipe",
73
+ stderr: "pipe",
74
+ })
75
+ const seedCode = await seed.exited
76
+ if (seedCode !== 0) {
77
+ const err = await new Response(seed.stderr).text()
78
+ throw new Error(`seed failed: ${err}`)
79
+ }
80
+
81
+ await tui(["close", "--session", SESSION])
82
+ const launch = await tui([
83
+ "launch",
84
+ "bun run src/index.tsx",
85
+ "--session", SESSION,
86
+ "--cols", "96",
87
+ "--rows", "40",
88
+ "--cwd", process.cwd(),
89
+ "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
90
+ "--env", "MOTEL_OTEL_ENABLED=false",
91
+ "--timeout", "15000",
92
+ ])
93
+ if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
94
+ await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
95
+ await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
96
+ }, 60_000)
97
+
98
+ afterAll(async () => {
99
+ if (canRun) await tui(["close", "--session", SESSION])
100
+ try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
101
+ })
102
+
103
+ it("fills the full-width trace details pane in narrow mode", async () => {
104
+ if (!canRun) return
105
+
106
+ await press("return")
107
+ const snap = await snapshot()
108
+ const divider = dividerWidth(snap)
109
+ const row = rootWaterfallRow(snap)
110
+
111
+ expect(divider).toBe(96)
112
+ expect(row).not.toBeNull()
113
+ expect(row!.length).toBeGreaterThanOrEqual(divider - 1)
114
+ }, 60_000)
115
+ })
@@ -0,0 +1,47 @@
1
+ import { useAtom } from "@effect/atom-react"
2
+ import { useEffect } from "react"
3
+ import {
4
+ attrFacetStateAtom,
5
+ attrPickerModeAtom,
6
+ initialAttrFacetState,
7
+ loadTraceAttributeKeys,
8
+ loadTraceAttributeValues,
9
+ selectedTraceServiceAtom,
10
+ } from "./state.ts"
11
+
12
+ // When the picker is open, load the current facet page (keys, or values for
13
+ // a specific key) and keep it in sync with the selected service. We key the
14
+ // effect off picker mode + service + target key so refetches happen on drill
15
+ // in/out and when the user switches services mid-pick.
16
+ export const useAttrFilterPicker = (selectedKey: string | null) => {
17
+ const [pickerMode] = useAtom(attrPickerModeAtom)
18
+ const [service] = useAtom(selectedTraceServiceAtom)
19
+ const [, setFacetState] = useAtom(attrFacetStateAtom)
20
+
21
+ useEffect(() => {
22
+ if (pickerMode === "off" || !service) {
23
+ setFacetState(initialAttrFacetState)
24
+ return
25
+ }
26
+ let cancelled = false
27
+ setFacetState({ status: "loading", key: pickerMode === "values" ? selectedKey : null, data: [], error: null })
28
+ const load = async () => {
29
+ try {
30
+ const rows = pickerMode === "keys"
31
+ ? await loadTraceAttributeKeys(service)
32
+ : selectedKey
33
+ ? await loadTraceAttributeValues(service, selectedKey)
34
+ : []
35
+ if (cancelled) return
36
+ setFacetState({ status: "ready", key: pickerMode === "values" ? selectedKey : null, data: rows, error: null })
37
+ } catch (err) {
38
+ if (cancelled) return
39
+ setFacetState({ status: "error", key: pickerMode === "values" ? selectedKey : null, data: [], error: err instanceof Error ? err.message : String(err) })
40
+ }
41
+ }
42
+ void load()
43
+ return () => {
44
+ cancelled = true
45
+ }
46
+ }, [pickerMode, service, selectedKey, setFacetState])
47
+ }