@kitlangton/motel 0.1.0 → 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/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
  }
@@ -60,8 +60,9 @@ const listRows = (snap: string): { readonly rows: readonly string[]; readonly se
60
60
  const leftHalf = raw.split("\u2502")[0] ?? raw
61
61
  const rightHalf = raw.includes("\u2502") ? raw.split("\u2502").slice(1).join("\u2502") : ""
62
62
 
63
- // Trace rows: left pane, `·` then `op #hash`.
64
- const rowMatch = leftHalf.match(/^\s+\u00b7\s+(\S+)\s+#/)
63
+ // Trace rows: left pane, `·` then the operation name as the first
64
+ // token. (Earlier versions appended `#<hash>`; that's been removed.)
65
+ const rowMatch = leftHalf.match(/^\s+\u00b7\s+(op[A-Z])\b/)
65
66
  if (rowMatch) rows.push(rowMatch[1]!)
66
67
 
67
68
  // Selected trace: right pane, line immediately after `TRACE DETAILS`
@@ -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
+ }
@@ -5,6 +5,12 @@ import type { TraceItem, TraceSummaryItem } from "../domain.ts"
5
5
  import { otelServerInstructions } from "../instructions.ts"
6
6
  import { copyToClipboard, traceUiUrl, webUiUrl } from "./format.ts"
7
7
  import {
8
+ activeAttrKeyAtom,
9
+ activeAttrValueAtom,
10
+ attrFacetStateAtom,
11
+ attrPickerIndexAtom,
12
+ attrPickerInputAtom,
13
+ attrPickerModeAtom,
8
14
  autoRefreshAtom,
9
15
  collapsedSpanIdsAtom,
10
16
  detailViewAtom,
@@ -22,6 +28,7 @@ import {
22
28
  type TraceSortMode,
23
29
  traceStateAtom,
24
30
  } from "./state.ts"
31
+ import { filterFacets } from "./AttrFilterModal.tsx"
25
32
  import { G_PREFIX_TIMEOUT_MS } from "./theme.ts"
26
33
  import { cycleThemeName, themeLabel } from "./theme.ts"
27
34
  import { getVisibleSpans } from "./Waterfall.tsx"
@@ -65,6 +72,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
65
72
  const [filterMode, setFilterMode] = useAtom(filterModeAtom)
66
73
  const [filterText, setFilterText] = useAtom(filterTextAtom)
67
74
  const [traceSort, setTraceSort] = useAtom(traceSortAtom)
75
+ const [pickerMode, setPickerMode] = useAtom(attrPickerModeAtom)
76
+ const [pickerInput, setPickerInput] = useAtom(attrPickerInputAtom)
77
+ const [pickerIndex, setPickerIndex] = useAtom(attrPickerIndexAtom)
78
+ const [attrFacets] = useAtom(attrFacetStateAtom)
79
+ const [activeAttrKey, setActiveAttrKey] = useAtom(activeAttrKeyAtom)
80
+ const [activeAttrValue, setActiveAttrValue] = useAtom(activeAttrValueAtom)
68
81
 
69
82
  const pendingGRef = useRef(false)
70
83
  const pendingGTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
@@ -73,12 +86,12 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
73
86
  const spanNavActive = detailView !== "service-logs" && selectedSpanIndex !== null
74
87
  const serviceLogNavActive = detailView === "service-logs"
75
88
 
76
- const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params })
89
+ const stateRef = useRef({ traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params })
77
90
  // Keep the keyboard handler's state mirror in sync before the next paint.
78
91
  // OpenTUI's own effect-event helper uses useLayoutEffect for this same reason:
79
92
  // rapid repeated keypresses can otherwise observe stale selection state.
80
93
  useLayoutEffect(() => {
81
- stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, ...params }
94
+ stateRef.current = { traceState, serviceLogState, selectedServiceLogIndex, selectedTheme, selectedTraceIndex, selectedSpanIndex, selectedTraceService, detailView, showHelp, collapsedSpanIds, spanNavActive, serviceLogNavActive, filterMode, filterText, autoRefresh, traceSort, pickerMode, pickerInput, pickerIndex, attrFacets, activeAttrKey, activeAttrValue, ...params }
82
95
  })
83
96
 
84
97
  const clearPendingG = () => {
@@ -230,7 +243,8 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
230
243
  if (s.serviceLogState.data.length === 0) return 0
231
244
  return Math.max(0, Math.min(current + direction * serviceLogPageSize, s.serviceLogState.data.length - 1))
232
245
  })
233
- } else if (s.spanNavActive && s.selectedTrace) {
246
+ } else if (s.spanNavActive) {
247
+ if (!s.selectedTrace) return
234
248
  const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
235
249
  setSelectedSpanIndex((current) => {
236
250
  if (visibleCount === 0) return null
@@ -257,6 +271,66 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
257
271
  useKeyboard((key) => {
258
272
  const s = $()
259
273
 
274
+ // Attribute picker modal owns the keyboard while open.
275
+ if (s.pickerMode !== "off") {
276
+ const rows = filterFacets(s.attrFacets.data, s.pickerInput)
277
+ const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(s.pickerIndex, rows.length - 1))
278
+ const move = (delta: number) => {
279
+ if (rows.length === 0) return
280
+ setPickerIndex(Math.max(0, Math.min(clampedIndex + delta, rows.length - 1)))
281
+ }
282
+ if (key.name === "escape") {
283
+ setPickerMode("off")
284
+ setPickerInput("")
285
+ setPickerIndex(0)
286
+ return
287
+ }
288
+ if (key.name === "up" || (key.ctrl && key.name === "p")) { move(-1); return }
289
+ if (key.name === "down" || (key.ctrl && key.name === "n")) { move(1); return }
290
+ if (key.name === "pageup") { move(-10); return }
291
+ if (key.name === "pagedown") { move(10); return }
292
+ if (key.name === "return" || key.name === "enter") {
293
+ const row = rows[clampedIndex]
294
+ if (!row) return
295
+ if (s.pickerMode === "keys") {
296
+ // Drill from keys → values for this key.
297
+ setActiveAttrKey(row.value)
298
+ setPickerMode("values")
299
+ setPickerInput("")
300
+ setPickerIndex(0)
301
+ } else {
302
+ // Apply: activeAttrKey is already set, now pin the value.
303
+ setActiveAttrValue(row.value)
304
+ setPickerMode("off")
305
+ setPickerInput("")
306
+ setPickerIndex(0)
307
+ s.flashNotice(`Filter: ${s.activeAttrKey}=${row.value}`)
308
+ }
309
+ return
310
+ }
311
+ if (key.name === "backspace") {
312
+ if (s.pickerInput.length > 0) {
313
+ setPickerInput(s.pickerInput.slice(0, -1))
314
+ setPickerIndex(0)
315
+ return
316
+ }
317
+ // At empty input in values mode, backspace walks back to keys.
318
+ if (s.pickerMode === "values") {
319
+ setPickerMode("keys")
320
+ setActiveAttrKey(null)
321
+ setPickerIndex(0)
322
+ return
323
+ }
324
+ return
325
+ }
326
+ if (key.name.length === 1 && !key.ctrl && !key.meta) {
327
+ setPickerInput(s.pickerInput + key.name)
328
+ setPickerIndex(0)
329
+ return
330
+ }
331
+ return
332
+ }
333
+
260
334
  // Filter mode: capture text input
261
335
  if (s.filterMode) {
262
336
  if (key.name === "escape") {
@@ -365,6 +439,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
365
439
  setSelectedSpanIndex(null)
366
440
  return
367
441
  }
442
+ // At the trace list, `esc` clears any applied attribute filter so
443
+ // there's a clean way back to the unfiltered list without hunting
444
+ // for the picker key.
445
+ if (s.activeAttrKey || s.activeAttrValue) {
446
+ setActiveAttrKey(null)
447
+ setActiveAttrValue(null)
448
+ s.flashNotice("Cleared attribute filter")
449
+ return
450
+ }
368
451
  return
369
452
  }
370
453
  if (key.name === "return" || key.name === "enter") {
@@ -416,6 +499,15 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
416
499
  setFilterMode(true)
417
500
  return
418
501
  }
502
+ if ((key.name === "f" || key.name === "F") && !key.ctrl && !key.meta) {
503
+ // Open attribute picker at the keys step. If a filter is already
504
+ // applied, reopening lets the user refine or switch.
505
+ setPickerMode("keys")
506
+ setPickerInput("")
507
+ setPickerIndex(0)
508
+ setActiveAttrKey(null)
509
+ return
510
+ }
419
511
  if (key.name === "tab") {
420
512
  toggleServiceLogsView()
421
513
  return
@@ -431,12 +523,17 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
431
523
  if (key.name === "up" || key.name === "k") {
432
524
  if (s.serviceLogNavActive) {
433
525
  moveServiceLogBy(-1)
434
- } else if (s.spanNavActive && s.selectedTrace) {
435
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
436
- setSelectedSpanIndex((current) => {
437
- if (current === null || visibleCount === 0) return 0
438
- return Math.max(0, current - 1)
439
- })
526
+ } else if (s.spanNavActive) {
527
+ // Locked to span nav; never fall through to trace-list nav while
528
+ // drilled in. If the trace detail is still loading, swallow the
529
+ // key instead of silently leaking it to the trace list.
530
+ if (s.selectedTrace) {
531
+ const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
532
+ setSelectedSpanIndex((current) => {
533
+ if (current === null || visibleCount === 0) return 0
534
+ return Math.max(0, current - 1)
535
+ })
536
+ }
440
537
  } else {
441
538
  moveTraceBy(-1)
442
539
  }
@@ -445,12 +542,14 @@ export const useKeyboardNav = (params: KeyboardNavParams) => {
445
542
  if (key.name === "down" || key.name === "j") {
446
543
  if (s.serviceLogNavActive) {
447
544
  moveServiceLogBy(1)
448
- } else if (s.spanNavActive && s.selectedTrace) {
449
- const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
450
- setSelectedSpanIndex((current) => {
451
- if (current === null || visibleCount === 0) return 0
452
- return Math.min(current + 1, visibleCount - 1)
453
- })
545
+ } else if (s.spanNavActive) {
546
+ if (s.selectedTrace) {
547
+ const visibleCount = getVisibleSpans(s.selectedTrace.spans, s.collapsedSpanIds).length
548
+ setSelectedSpanIndex((current) => {
549
+ if (current === null || visibleCount === 0) return 0
550
+ return Math.min(current + 1, visibleCount - 1)
551
+ })
552
+ }
454
553
  } else {
455
554
  moveTraceBy(1)
456
555
  }
@@ -3,7 +3,8 @@ import type { TraceSpanItem } from "../domain.ts"
3
3
  import {
4
4
  findFirstChildIndex,
5
5
  findParentIndex,
6
- getWaterfallColumns,
6
+ getWaterfallLayout,
7
+ getWaterfallSuffixMetrics,
7
8
  getVisibleSpans,
8
9
  } from "./Waterfall.tsx"
9
10
  import { resolveCollapseStep } from "./waterfallNav.ts"
@@ -130,13 +131,27 @@ describe("getVisibleSpans", () => {
130
131
  })
131
132
  })
132
133
 
133
- describe("getWaterfallColumns", () => {
134
- it("pads duration and log columns to fill the reserved width", () => {
134
+ describe("getWaterfallSuffixMetrics", () => {
135
+ it("uses the widest visible duration as the shared suffix width", () => {
136
+ const spans = [
137
+ { spanId: "a", durationMs: 1 },
138
+ { spanId: "b", durationMs: 57_000 },
139
+ { spanId: "c", durationMs: 120 },
140
+ ]
141
+ const metrics = getWaterfallSuffixMetrics(spans)
142
+ // `120ms` = 5 is the widest
143
+ expect(metrics.maxDurationWidth).toBe(5)
144
+ expect(metrics.suffixWidth).toBe(5)
145
+ })
146
+
147
+ it("layout reserves the suffix once and leaves the rest for the bar", () => {
135
148
  const contentWidth = 72
136
- const columns = getWaterfallColumns(contentWidth, 153_000, 1, 0)
137
- expect(columns.durationCell.length).toBe(columns.durationWidth)
138
- expect(columns.logCell.length).toBe(columns.logWidth)
139
- expect(columns.labelMaxWidth + 1 + columns.barWidth + 1 + columns.durationCell.length + columns.logCell.length).toBe(contentWidth)
149
+ const metrics = getWaterfallSuffixMetrics(
150
+ [{ spanId: "a", durationMs: 57_000 }, { spanId: "b", durationMs: 1 }],
151
+ )
152
+ const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
153
+ // label + 1 (gap before bar) + bar + 1 (gap before suffix) + suffix = contentWidth
154
+ expect(labelMaxWidth + 1 + barWidth + 1 + metrics.suffixWidth).toBe(contentWidth)
140
155
  })
141
156
  })
142
157