@kitlangton/motel 0.2.4 → 0.2.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/AGENTS.md +23 -8
  2. package/README.md +13 -2
  3. package/package.json +35 -19
  4. package/skills/motel-debug/SKILL.md +203 -0
  5. package/skills/motel-debug/references/effect.md +38 -0
  6. package/src/App.tsx +12 -5
  7. package/src/StartupGate.tsx +289 -0
  8. package/src/cli.ts +15 -16
  9. package/src/config.ts +7 -1
  10. package/src/daemon.test.ts +332 -51
  11. package/src/daemon.ts +105 -153
  12. package/src/httpApi.ts +1 -0
  13. package/src/httpListPolicy.test.ts +76 -0
  14. package/src/httpListPolicy.ts +129 -0
  15. package/src/index.tsx +9 -2
  16. package/src/localServer.ts +194 -313
  17. package/src/mcp.ts +2 -1
  18. package/src/motel.ts +0 -2
  19. package/src/opentui-jsx.d.ts +11 -0
  20. package/src/otlp.test.ts +65 -0
  21. package/src/otlp.ts +20 -0
  22. package/src/otlpProtobuf.ts +35 -0
  23. package/src/registry.ts +37 -11
  24. package/src/runtime.ts +2 -6
  25. package/src/services/AsyncIngest.ts +22 -8
  26. package/src/services/LogQueryService.ts +13 -27
  27. package/src/services/TelemetryQuery.ts +62 -0
  28. package/src/services/TelemetryStore.ts +546 -231
  29. package/src/services/TraceQueryService.ts +22 -56
  30. package/src/services/ingestRpc.ts +2 -4
  31. package/src/services/queryRpc.ts +15 -0
  32. package/src/services/telemetryQueryWorker.ts +32 -0
  33. package/src/services/telemetryWorker.ts +5 -8
  34. package/src/startupBench.ts +19 -0
  35. package/src/storybook/aiChatStory.tsx +1 -1
  36. package/src/telemetry.test.ts +307 -41
  37. package/src/ui/AiChatView.tsx +1 -1
  38. package/src/ui/AttrFilterModal.tsx +1 -1
  39. package/src/ui/ServiceLogs.tsx +10 -7
  40. package/src/ui/SpanContentView.tsx +24 -21
  41. package/src/ui/TraceDetailsPane.tsx +1 -1
  42. package/src/ui/TraceList.tsx +1 -1
  43. package/src/ui/aiState.ts +10 -22
  44. package/src/ui/app/TraceWorkspace.tsx +2 -1
  45. package/src/ui/app/useAppLayout.ts +1 -1
  46. package/src/ui/app/useTraceScreenData.ts +35 -23
  47. package/src/ui/atoms.ts +1 -1
  48. package/src/ui/cachedLoader.test.ts +23 -0
  49. package/src/ui/cachedLoader.ts +60 -0
  50. package/src/ui/loaders.ts +34 -53
  51. package/src/ui/persistence.ts +3 -3
  52. package/src/ui/primitives.tsx +1 -1
  53. package/src/ui/state.ts +2 -0
  54. package/src/ui/theme.ts +7 -5
  55. package/src/ui/traceDetailsWidth.repro.test.ts +12 -1
  56. package/src/ui/traceSortNav.repro.seed.ts +1 -1
  57. package/src/ui/traceSortNav.repro.test.ts +12 -2
  58. package/src/ui/useAttrFilterPicker.ts +10 -8
  59. package/src/ui/useKeyboardNav.ts +28 -5
  60. package/src/ui/waterfallNav.repro.seed.ts +1 -1
  61. package/src/ui/waterfallNav.repro.test.ts +16 -8
  62. package/web/dist/assets/index-B01z9BaO.css +2 -0
  63. package/web/dist/assets/index-M86tcih5.js +22 -0
  64. package/web/dist/index.html +2 -2
  65. package/web/dist/assets/index-DnyVo03x.js +0 -27
  66. package/web/dist/assets/index-DzuHNBGV.css +0 -2
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef } from "react"
3
3
  import type { LogItem } from "../domain.ts"
4
4
  import { fitCell, formatLogTimestamp, formatTimestamp, logHeadline, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
5
5
  import { Divider, PlainLine, TextLine } from "./primitives.tsx"
6
- import type { ServiceLogState } from "./state.ts"
6
+ import type { ServiceLogState } from "./atoms.ts"
7
7
  import { colors, SEPARATOR } from "./theme.ts"
8
8
 
9
9
  export const ServiceLogsView = ({
@@ -26,6 +26,15 @@ export const ServiceLogsView = ({
26
26
  const traceWidth = 8
27
27
  const messageWidth = Math.max(16, contentWidth - timeWidth - levelWidth - traceWidth - 3)
28
28
 
29
+ // Hooks must be called unconditionally — keep them above the early
30
+ // returns. The data-dependent values below all tolerate a 0-length
31
+ // data array, so this stays correct in the empty/loading/error cases.
32
+ const scrollRef = useRef<ScrollBoxRenderable | null>(null)
33
+ const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
34
+ useLayoutEffect(() => {
35
+ scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
36
+ }, [safeSelectedIndex])
37
+
29
38
  if (logsState.status === "loading" && logsState.data.length === 0) {
30
39
  return <PlainLine text="Loading recent service logs..." fg={colors.count} />
31
40
  }
@@ -38,8 +47,6 @@ export const ServiceLogsView = ({
38
47
  return <PlainLine text={`No logs captured yet for service ${serviceName ?? "unknown"}.`} fg={colors.muted} />
39
48
  }
40
49
 
41
- const scrollRef = useRef<ScrollBoxRenderable | null>(null)
42
- const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
43
50
  const selectedLog = logsState.data[safeSelectedIndex] ?? null
44
51
  const detailWidth = Math.max(16, contentWidth - 2)
45
52
  const detailBodyLines = selectedLog ? wrapTextLines(selectedLog.body, detailWidth, 6) : []
@@ -47,10 +54,6 @@ export const ServiceLogsView = ({
47
54
  const detailHeight = selectedLog ? 3 + detailBodyLines.length + detailAttributeLines.length : 0
48
55
  const listHeight = Math.max(4, bodyLines - detailHeight - 1)
49
56
 
50
- useLayoutEffect(() => {
51
- scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
52
- }, [safeSelectedIndex])
53
-
54
57
  return (
55
58
  <box flexDirection="column">
56
59
  {selectedLog ? (
@@ -47,6 +47,25 @@ export const SpanContentView = ({
47
47
  readonly paneWidth: number
48
48
  readonly selectedAttrIndex: number
49
49
  }) => {
50
+ const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
51
+ const tags = span?.tags
52
+
53
+ // Block layout is memoised on tags identity — otherwise every j/k press
54
+ // would rewrap every attribute's value even though only the highlight
55
+ // moved. Hooks must be called unconditionally, so this lives above the
56
+ // `!span` early return and the memo handles the empty case itself.
57
+ const blocks = useMemo<readonly AttrBlock[]>(() => {
58
+ if (!tags) return []
59
+ return Object.entries(tags).map(([key, value]) => {
60
+ // Word-wrap with a generous line cap — the view is scrollable so
61
+ // we can afford to show the whole value rather than forcing an
62
+ // ellipsis. 200 lines covers enormous LLM prompts without
63
+ // blowing up the render tree.
64
+ const valueLines = wrapTextLines(value, valueWrapWidth, 200)
65
+ return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
66
+ })
67
+ }, [tags, valueWrapWidth])
68
+
50
69
  if (!span) {
51
70
  return (
52
71
  <box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_CONTENT_HEADER_ROWS} overflow="hidden">
@@ -61,25 +80,9 @@ export const SpanContentView = ({
61
80
  )
62
81
  }
63
82
 
64
- const entries = Object.entries(span.tags)
65
- const selected = Math.max(0, Math.min(selectedAttrIndex, entries.length - 1))
66
- const valueWrapWidth = Math.max(16, contentWidth - VALUE_INDENT.length - CURSOR_WIDTH)
83
+ const selected = Math.max(0, Math.min(selectedAttrIndex, blocks.length - 1))
67
84
  const aiFlag = isAiSpan(span.tags)
68
85
 
69
- // Block layout is memoised on entries identity — otherwise every j/k
70
- // press would rewrap every attribute's value even though only the
71
- // highlight moved.
72
- const blocks = useMemo<readonly AttrBlock[]>(() => {
73
- return entries.map(([key, value]) => {
74
- // Word-wrap with a generous line cap — the view is scrollable so
75
- // we can afford to show the whole value rather than forcing an
76
- // ellipsis. 200 lines covers enormous LLM prompts without
77
- // blowing up the render tree.
78
- const valueLines = wrapTextLines(value, valueWrapWidth, 200)
79
- return { key, valueLines, rowCount: 1 + Math.max(1, valueLines.length) }
80
- })
81
- }, [entries, valueWrapWidth])
82
-
83
86
  // Viewport: pick the contiguous window of blocks that (a) fits inside
84
87
  // bodyLines and (b) contains the selected block. We find the first
85
88
  // start index whose accumulated row count from there through `selected`
@@ -137,14 +140,14 @@ export const SpanContentView = ({
137
140
  <span fg={colors.separator}>{SEPARATOR}</span>
138
141
  <span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
139
142
  <span fg={colors.separator}>{SEPARATOR}</span>
140
- <span fg={colors.count}>{`${entries.length} tags`}</span>
143
+ <span fg={colors.count}>{`${blocks.length} tags`}</span>
141
144
  <span fg={colors.separator}>{SEPARATOR}</span>
142
- <span fg={colors.muted}>{`${selected + 1}/${entries.length}`}</span>
145
+ <span fg={colors.muted}>{`${selected + 1}/${blocks.length}`}</span>
143
146
  </TextLine>
144
147
  </box>
145
148
  <Divider width={paneWidth} />
146
149
  <box flexDirection="column" paddingLeft={1} paddingRight={1}>
147
- {entries.length === 0 ? (
150
+ {blocks.length === 0 ? (
148
151
  <PlainLine text="No tags on this span." fg={colors.muted} />
149
152
  ) : (
150
153
  visible.map((block, offset) => {
@@ -165,7 +168,7 @@ export const SpanContentView = ({
165
168
  </TextLine>
166
169
  ) : (
167
170
  block.valueLines.map((line, i) => (
168
- <TextLine key={i}>
171
+ <TextLine key={`${block.key}-${i}`}>
169
172
  <span fg={colors.separator}>{VALUE_INDENT}</span>
170
173
  <span fg={isSelected ? colors.text : colors.muted}>{line}</span>
171
174
  </TextLine>
@@ -5,7 +5,7 @@ import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./pr
5
5
  import { WaterfallTimeline } from "./Waterfall.tsx"
6
6
  import { computeMatchingSpanIds } from "./waterfallFilter.ts"
7
7
  import { getVisibleSpans } from "./waterfallModel.ts"
8
- import type { LoadStatus } from "./state.ts"
8
+ import type { LoadStatus } from "./atoms.ts"
9
9
  import { colors, SEPARATOR } from "./theme.ts"
10
10
 
11
11
  /**
@@ -3,7 +3,7 @@ import { useLayoutEffect, useRef, useState } from "react"
3
3
  import type { TraceSummaryItem } from "../domain.ts"
4
4
  import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
5
5
  import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
6
- import type { LoadStatus } from "./state.ts"
6
+ import type { LoadStatus } from "./atoms.ts"
7
7
  import { colors } from "./theme.ts"
8
8
 
9
9
  const getTraceRowLayout = (contentWidth: number) => {
package/src/ui/aiState.ts CHANGED
@@ -2,8 +2,9 @@ import { Effect } from "effect"
2
2
  import * as Atom from "effect/unstable/reactivity/Atom"
3
3
  import type { AiCallDetail } from "../domain.ts"
4
4
  import { queryRuntime } from "../runtime.ts"
5
- import { TraceQueryService } from "../services/TraceQueryService.ts"
5
+ import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
6
6
  import type { LoadStatus } from "./atoms.ts"
7
+ import { makeCachedLoader } from "./cachedLoader.ts"
7
8
 
8
9
  // AI chat view (full-screen when drilled into an `isAiSpan` span).
9
10
  // ---------------------------------------------------------------------
@@ -37,35 +38,22 @@ export const initialAiCallDetailState: AiCallDetailState = {
37
38
  export const aiCallDetailStateAtom = Atom.make(initialAiCallDetailState).pipe(Atom.keepAlive)
38
39
 
39
40
  export const loadAiCallDetail = (spanId: string) =>
40
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getAiCall(spanId)))
41
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getAiCall(spanId)))
41
42
 
42
43
  // AI call detail cache: the `ai.prompt` payload can easily be 50KB+ and
43
44
  // we don't want to re-hit SQLite every time j/k moves the selection
44
45
  // between adjacent AI spans. Cleared alongside the other per-refresh
45
46
  // caches in `useTraceScreenData`.
46
- const aiCallDetailCache = new Map<string, AiCallDetail | null>()
47
- const aiCallDetailInflight = new Map<string, Promise<AiCallDetail | null>>()
47
+ const aiCallDetailLoader = makeCachedLoader<string, AiCallDetail | null>({
48
+ load: loadAiCallDetail,
49
+ })
48
50
 
49
51
  export const getCachedAiCallDetail = (spanId: string): AiCallDetail | null | undefined =>
50
- aiCallDetailCache.has(spanId) ? aiCallDetailCache.get(spanId) ?? null : undefined
52
+ aiCallDetailLoader.get(spanId)
51
53
 
52
- export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> => {
53
- if (aiCallDetailCache.has(spanId)) return Promise.resolve(aiCallDetailCache.get(spanId) ?? null)
54
- const existing = aiCallDetailInflight.get(spanId)
55
- if (existing) return existing
56
- const request = loadAiCallDetail(spanId)
57
- .then((data) => {
58
- aiCallDetailCache.set(spanId, data)
59
- return data
60
- })
61
- .finally(() => {
62
- aiCallDetailInflight.delete(spanId)
63
- })
64
- aiCallDetailInflight.set(spanId, request)
65
- return request
66
- }
54
+ export const ensureAiCallDetail = (spanId: string): Promise<AiCallDetail | null> =>
55
+ aiCallDetailLoader.ensure(spanId)
67
56
 
68
57
  export const invalidateAiCallDetailCache = () => {
69
- aiCallDetailCache.clear()
70
- aiCallDetailInflight.clear()
58
+ aiCallDetailLoader.invalidate()
71
59
  }
@@ -6,7 +6,8 @@ import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from
6
6
  import { ServiceLogsView } from "../ServiceLogs.tsx"
7
7
  import { SpanContentView } from "../SpanContentView.tsx"
8
8
  import { SpanDetailPane } from "../SpanDetailPane.tsx"
9
- import type { AiCallDetailState, DetailView, LogState, ServiceLogState, TraceDetailState } from "../state.ts"
9
+ import type { DetailView, LogState, ServiceLogState, TraceDetailState } from "../atoms.ts"
10
+ import type { AiCallDetailState } from "../aiState.ts"
10
11
  import { colors, SEPARATOR } from "../theme.ts"
11
12
  import { TraceDetailsPane } from "../TraceDetailsPane.tsx"
12
13
  import type { TraceListProps } from "../TraceList.tsx"
@@ -1,6 +1,6 @@
1
1
  import { useMemo } from "react"
2
2
  import { fitCell } from "../format.ts"
3
- import type { DetailView } from "../state.ts"
3
+ import type { DetailView } from "../atoms.ts"
4
4
 
5
5
  interface UseAppLayoutInput {
6
6
  readonly width: number | undefined
@@ -5,34 +5,17 @@ import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
5
5
  import {
6
6
  activeAttrKeyAtom,
7
7
  activeAttrValueAtom,
8
- aiCallDetailStateAtom,
9
8
  autoRefreshAtom,
10
- chatDetailChunkIdAtom,
11
- chatDetailScrollOffsetAtom,
12
9
  collapsedSpanIdsAtom,
13
10
  detailViewAtom,
14
- ensureAiCallDetail,
15
- ensureTraceAttributeKeys,
16
11
  filterModeAtom,
17
12
  filterTextAtom,
18
- getCachedAiCallDetail,
19
- initialAiCallDetailState,
20
13
  initialLogState,
21
14
  initialServiceLogState,
22
15
  initialTraceDetailState,
23
- invalidateAiCallDetailCache,
24
- invalidateFacetCaches,
25
- loadFilteredTraceSummaries,
26
- loadRecentTraceSummaries,
27
- loadServiceLogs,
28
- loadTraceDetail,
29
- loadTraceLogs,
30
- loadTraceServices,
31
16
  logStateAtom,
32
- persistSelectedService,
33
17
  refreshNonceAtom,
34
18
  selectedAttrIndexAtom,
35
- selectedChatChunkIdAtom,
36
19
  selectedServiceLogIndexAtom,
37
20
  selectedSpanIndexAtom,
38
21
  selectedTraceIndexAtom,
@@ -43,7 +26,28 @@ import {
43
26
  type TraceSortMode,
44
27
  traceSortAtom,
45
28
  traceStateAtom,
46
- } from "../state.ts"
29
+ } from "../atoms.ts"
30
+ import {
31
+ aiCallDetailStateAtom,
32
+ chatDetailChunkIdAtom,
33
+ chatDetailScrollOffsetAtom,
34
+ ensureAiCallDetail,
35
+ getCachedAiCallDetail,
36
+ initialAiCallDetailState,
37
+ invalidateAiCallDetailCache,
38
+ selectedChatChunkIdAtom,
39
+ } from "../aiState.ts"
40
+ import {
41
+ ensureTraceAttributeKeys,
42
+ ensureTraceAttributeValues,
43
+ loadFilteredTraceSummaries,
44
+ loadRecentTraceSummaries,
45
+ loadServiceLogs,
46
+ loadTraceDetail,
47
+ loadTraceLogs,
48
+ loadTraceServices,
49
+ } from "../loaders.ts"
50
+ import { persistSelectedService } from "../persistence.ts"
47
51
  import { isAiSpan } from "../../domain.ts"
48
52
  import { buildChunks, type Chunk } from "../aiChatModel.ts"
49
53
  import { parseFilterText } from "../filterParser.ts"
@@ -166,17 +170,25 @@ export const useTraceScreenData = () => {
166
170
  serviceLogCacheRef.current.clear()
167
171
  traceDetailInflightRef.current.clear()
168
172
  traceLogInflightRef.current.clear()
169
- invalidateFacetCaches()
170
173
  invalidateAiCallDetailCache()
171
174
  }, [refreshNonce])
172
175
 
173
176
  // Pre-warm the attribute picker facet keys for the currently-selected
174
- // service so pressing `f` feels instant. Fire-and-forget; errors are
175
- // surfaced when the user actually opens the picker.
177
+ // service so pressing `f` feels instant. Once keys are known, also
178
+ // prefetch the value lists for the first few visible keys so the common
179
+ // path of opening `f`, picking a top key, and reopening again stays
180
+ // near-instant. Fire-and-forget; errors are surfaced when the user
181
+ // actually opens the picker.
176
182
  useEffect(() => {
177
183
  if (!selectedTraceService) return
178
- void ensureTraceAttributeKeys(selectedTraceService).catch(() => {})
179
- }, [selectedTraceService, refreshNonce])
184
+ void ensureTraceAttributeKeys(selectedTraceService)
185
+ .then((entry) => Promise.allSettled(
186
+ entry.data
187
+ .slice(0, 6)
188
+ .map((row) => ensureTraceAttributeValues(selectedTraceService, row.value)),
189
+ ))
190
+ .catch(() => {})
191
+ }, [selectedTraceService])
180
192
 
181
193
  useEffect(() => {
182
194
  let cancelled = false
package/src/ui/atoms.ts CHANGED
@@ -87,7 +87,7 @@ export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.ke
87
87
  export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
88
88
  export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
89
89
  export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
90
- export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
90
+ export const autoRefreshAtom = Atom.make(true).pipe(Atom.keepAlive)
91
91
  export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
92
92
  export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
93
93
 
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "bun:test"
2
+ import { makeCachedLoader } from "./cachedLoader.ts"
3
+
4
+ describe("makeCachedLoader", () => {
5
+ it("ensures cached values without loading twice", async () => {
6
+ let loads = 0
7
+ const loader = makeCachedLoader<string, number>({ load: async () => ++loads })
8
+
9
+ expect(await loader.ensure("key")).toBe(1)
10
+ expect(await loader.ensure("key")).toBe(1)
11
+ expect(loads).toBe(1)
12
+ })
13
+
14
+ it("refreshes stale values while publishing the updated cache", async () => {
15
+ let value = 1
16
+ const loader = makeCachedLoader<string, number>({ load: async () => value })
17
+
18
+ expect(await loader.ensure("key")).toBe(1)
19
+ value = 2
20
+ expect(await loader.refresh("key")).toBe(2)
21
+ expect(loader.get("key")).toBe(2)
22
+ })
23
+ })
@@ -0,0 +1,60 @@
1
+ // Generic cache with inflight-promise dedup, used by the facet and AI
2
+ // call detail caches. Both previously duplicated this pattern by hand:
3
+ // a Map for the cached value, a parallel Map for in-flight promises,
4
+ // and three small exports (get / ensure / invalidate). This factory is
5
+ // the seam; callers only need to supply a loader and (optionally) a
6
+ // hash function for compound keys.
7
+
8
+ export interface CachedLoader<K, V> {
9
+ /** Synchronous cache lookup. Returns `undefined` if the key has never been loaded. */
10
+ readonly get: (key: K) => V | undefined
11
+ /** Resolves with the cached value if present, otherwise loads it (deduplicating concurrent calls for the same key). */
12
+ readonly ensure: (key: K) => Promise<V>
13
+ /** Loads the latest value while deduplicating any load already running for this key. */
14
+ readonly refresh: (key: K) => Promise<V>
15
+ /** Drops every cached value and aborts dedup tracking for in-flight loads. */
16
+ readonly invalidate: () => void
17
+ }
18
+
19
+ export interface CachedLoaderOptions<K, V> {
20
+ readonly load: (key: K) => Promise<V>
21
+ /** Required when `K` is a compound type that can't be used as a Map key directly. */
22
+ readonly hash?: (key: K) => string
23
+ }
24
+
25
+ export const makeCachedLoader = <K, V>(opts: CachedLoaderOptions<K, V>): CachedLoader<K, V> => {
26
+ const hash = opts.hash ?? ((k) => k as unknown as string)
27
+ const cache = new Map<string, V>()
28
+ const inflight = new Map<string, Promise<V>>()
29
+
30
+ const get = (key: K) => cache.get(hash(key))
31
+
32
+ const refresh = (key: K): Promise<V> => {
33
+ const h = hash(key)
34
+ const existing = inflight.get(h)
35
+ if (existing) return existing
36
+ const request = opts.load(key)
37
+ .then((data) => {
38
+ cache.set(h, data)
39
+ return data
40
+ })
41
+ .finally(() => {
42
+ inflight.delete(h)
43
+ })
44
+ inflight.set(h, request)
45
+ return request
46
+ }
47
+
48
+ const ensure = (key: K): Promise<V> => {
49
+ const h = hash(key)
50
+ if (cache.has(h)) return Promise.resolve(cache.get(h) as V)
51
+ return refresh(key)
52
+ }
53
+
54
+ const invalidate = () => {
55
+ cache.clear()
56
+ inflight.clear()
57
+ }
58
+
59
+ return { get, ensure, refresh, invalidate }
60
+ }
package/src/ui/loaders.ts CHANGED
@@ -1,14 +1,14 @@
1
1
  import { Effect } from "effect"
2
2
  import { config } from "../config.ts"
3
3
  import { queryRuntime } from "../runtime.ts"
4
- import { LogQueryService } from "../services/LogQueryService.ts"
5
- import { TraceQueryService } from "../services/TraceQueryService.ts"
4
+ import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
5
+ import { makeCachedLoader } from "./cachedLoader.ts"
6
6
 
7
7
  export const loadTraceServices = () =>
8
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
8
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
9
9
 
10
10
  export const loadRecentTraceSummaries = (serviceName: string) =>
11
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
11
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceSummaries(serviceName)))
12
12
 
13
13
  /**
14
14
  * Server-side trace summary search. Accepts any combination of:
@@ -29,7 +29,7 @@ export const loadFilteredTraceSummaries = (
29
29
  readonly aiText?: string | null
30
30
  },
31
31
  ) =>
32
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
32
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.searchTraceSummaries({
33
33
  serviceName,
34
34
  attributeFilters: options.attributeFilters,
35
35
  aiText: options.aiText ?? null,
@@ -37,10 +37,10 @@ export const loadFilteredTraceSummaries = (
37
37
  })))
38
38
 
39
39
  export const loadTraceAttributeKeys = (serviceName: string) =>
40
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
40
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
41
41
 
42
42
  export const loadTraceAttributeValues = (serviceName: string, key: string) =>
43
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
43
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
44
44
 
45
45
  // ---------------------------------------------------------------------------
46
46
  // Facet cache (drives the `f` attribute filter picker)
@@ -56,65 +56,46 @@ export interface FacetCacheEntry {
56
56
  readonly fetchedAt: Date
57
57
  }
58
58
 
59
- const facetKeysCache = new Map<string, FacetCacheEntry>()
60
- const facetValuesCache = new Map<string, FacetCacheEntry>()
61
- const facetKeysInflight = new Map<string, Promise<FacetCacheEntry>>()
62
- const facetValuesInflight = new Map<string, Promise<FacetCacheEntry>>()
59
+ const wrapWithTimestamp = (data: readonly FacetRow[]): FacetCacheEntry => ({ data, fetchedAt: new Date() })
63
60
 
64
- const valuesKey = (service: string, key: string) => `${service}\u0000${key}`
61
+ const facetKeysLoader = makeCachedLoader<string, FacetCacheEntry>({
62
+ load: (service) => loadTraceAttributeKeys(service).then(wrapWithTimestamp),
63
+ })
64
+
65
+ const facetValuesLoader = makeCachedLoader<{ readonly service: string; readonly key: string }, FacetCacheEntry>({
66
+ hash: ({ service, key }) => `${service}\u0000${key}`,
67
+ load: ({ service, key }) => loadTraceAttributeValues(service, key).then(wrapWithTimestamp),
68
+ })
65
69
 
66
70
  export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
67
- facetKeysCache.get(service) ?? null
71
+ facetKeysLoader.get(service) ?? null
68
72
 
69
73
  export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
70
- facetValuesCache.get(valuesKey(service, key)) ?? null
71
-
72
- export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> => {
73
- const existing = facetKeysInflight.get(service)
74
- if (existing) return existing
75
- const request = loadTraceAttributeKeys(service)
76
- .then((data) => {
77
- const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
78
- facetKeysCache.set(service, entry)
79
- return entry
80
- })
81
- .finally(() => {
82
- facetKeysInflight.delete(service)
83
- })
84
- facetKeysInflight.set(service, request)
85
- return request
86
- }
74
+ facetValuesLoader.get({ service, key }) ?? null
87
75
 
88
- export const ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> => {
89
- const cacheKey = valuesKey(service, key)
90
- const existing = facetValuesInflight.get(cacheKey)
91
- if (existing) return existing
92
- const request = loadTraceAttributeValues(service, key)
93
- .then((data) => {
94
- const entry = { data, fetchedAt: new Date() } satisfies FacetCacheEntry
95
- facetValuesCache.set(cacheKey, entry)
96
- return entry
97
- })
98
- .finally(() => {
99
- facetValuesInflight.delete(cacheKey)
100
- })
101
- facetValuesInflight.set(cacheKey, request)
102
- return request
103
- }
76
+ export const ensureTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> =>
77
+ facetKeysLoader.ensure(service)
78
+
79
+ export const refreshTraceAttributeKeys = (service: string): Promise<FacetCacheEntry> =>
80
+ facetKeysLoader.refresh(service)
81
+
82
+ export const ensureTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> =>
83
+ facetValuesLoader.ensure({ service, key })
84
+
85
+ export const refreshTraceAttributeValues = (service: string, key: string): Promise<FacetCacheEntry> =>
86
+ facetValuesLoader.refresh({ service, key })
104
87
 
105
88
  /** Called from the refreshNonce effect alongside the trace / log cache clears. */
106
89
  export const invalidateFacetCaches = () => {
107
- facetKeysCache.clear()
108
- facetValuesCache.clear()
109
- facetKeysInflight.clear()
110
- facetValuesInflight.clear()
90
+ facetKeysLoader.invalidate()
91
+ facetValuesLoader.invalidate()
111
92
  }
112
93
 
113
94
  export const loadTraceDetail = (traceId: string) =>
114
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
95
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
115
96
 
116
97
  export const loadTraceLogs = (traceId: string) =>
117
- queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
98
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
118
99
 
119
100
  export const loadServiceLogs = (serviceName: string) =>
120
- queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
101
+ queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listRecentLogs(serviceName)))
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs"
2
2
  import { dirname } from "node:path"
3
3
  import { config } from "../config.ts"
4
- import type { ThemeName } from "./theme.ts"
4
+ import { defaultThemeName, type ThemeName } from "./theme.ts"
5
5
 
6
6
  const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
7
7
 
@@ -26,9 +26,9 @@ const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
26
26
  export const readLastTheme = (): ThemeName => {
27
27
  try {
28
28
  const raw = readFileSync(lastThemePath, "utf-8").trim()
29
- return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
29
+ return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : defaultThemeName
30
30
  } catch {
31
- return "motel-default"
31
+ return defaultThemeName
32
32
  }
33
33
  }
34
34
 
@@ -2,7 +2,7 @@ import { RGBA, TextAttributes } from "@opentui/core"
2
2
  import { Children } from "react"
3
3
  import { colors } from "./theme.ts"
4
4
  import { fitCell, truncateText } from "./format.ts"
5
- import type { DetailView } from "./state.ts"
5
+ import type { DetailView } from "./atoms.ts"
6
6
 
7
7
  export const BlankRow = () => <box height={1} />
8
8
 
package/src/ui/state.ts CHANGED
@@ -1,3 +1,5 @@
1
+ // Compatibility barrel retained for published deep imports. New modules
2
+ // should import from the focused state/cache modules directly.
1
3
  export * from "./atoms.ts"
2
4
  export * from "./persistence.ts"
3
5
  export * from "./loaders.ts"
package/src/ui/theme.ts CHANGED
@@ -135,13 +135,15 @@ export const themes = {
135
135
 
136
136
  export type ThemeName = keyof typeof themes
137
137
 
138
- export const themeOrder: readonly ThemeName[] = ["motel-default", "tokyo-night", "catppuccin"]
138
+ export const defaultThemeName: ThemeName = "tokyo-night"
139
139
 
140
- export const colors: ThemeColors = { ...motelDefaultTheme.colors }
141
- export const waterfallColors: ThemeWaterfallColors = { ...motelDefaultTheme.waterfall }
140
+ export const themeOrder: readonly ThemeName[] = ["tokyo-night", "catppuccin", "motel-default"]
141
+
142
+ export const colors: ThemeColors = { ...themes[defaultThemeName].colors }
143
+ export const waterfallColors: ThemeWaterfallColors = { ...themes[defaultThemeName].waterfall }
142
144
 
143
145
  export const applyTheme = (name: ThemeName) => {
144
- const theme = themes[name] ?? motelDefaultTheme
146
+ const theme = themes[name] ?? themes[defaultThemeName]
145
147
  Object.assign(colors, theme.colors)
146
148
  Object.assign(waterfallColors, theme.waterfall)
147
149
  return theme
@@ -152,7 +154,7 @@ export const cycleThemeName = (current: ThemeName) => {
152
154
  return themeOrder[nextIndex] ?? themeOrder[0]
153
155
  }
154
156
 
155
- export const themeLabel = (name: ThemeName) => themes[name]?.label ?? motelDefaultTheme.label
157
+ export const themeLabel = (name: ThemeName) => themes[name]?.label ?? themes[defaultThemeName].label
156
158
 
157
159
  export const SEPARATOR = " \u00b7 "
158
160
  export const G_PREFIX_TIMEOUT_MS = 500
@@ -52,6 +52,8 @@ const rootWaterfallRow = (snap: string) =>
52
52
  describe("trace details waterfall width (end-to-end TUI)", () => {
53
53
  const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
54
54
  const dbPath = join(tempDir, "telemetry.sqlite")
55
+ const port = 45_000 + Math.floor(Math.random() * 5_000)
56
+ const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
55
57
  const lastServicePath = join(tempDir, "last-service.txt")
56
58
  let canRun = false
57
59
 
@@ -66,6 +68,7 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
66
68
  cwd: process.cwd(),
67
69
  env: {
68
70
  ...process.env,
71
+ ...daemonEnv,
69
72
  MOTEL_OTEL_DB_PATH: dbPath,
70
73
  MOTEL_OTEL_ENABLED: "false",
71
74
  },
@@ -87,6 +90,10 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
87
90
  "--rows", "40",
88
91
  "--cwd", process.cwd(),
89
92
  "--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
93
+ "--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
94
+ "--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
95
+ "--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
96
+ "--env", `MOTEL_OTEL_PORT=${port}`,
90
97
  "--env", "MOTEL_OTEL_ENABLED=false",
91
98
  "--timeout", "15000",
92
99
  ])
@@ -96,7 +103,11 @@ describe("trace details waterfall width (end-to-end TUI)", () => {
96
103
  }, 60_000)
97
104
 
98
105
  afterAll(async () => {
99
- if (canRun) await tui(["close", "--session", SESSION])
106
+ if (canRun) {
107
+ await tui(["close", "--session", SESSION])
108
+ const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
109
+ await stop.exited
110
+ }
100
111
  try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
101
112
  })
102
113