@kitlangton/motel 0.2.0 → 0.2.4

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 (48) hide show
  1. package/AGENTS.md +5 -0
  2. package/package.json +7 -5
  3. package/src/App.tsx +233 -59
  4. package/src/daemon.test.ts +213 -6
  5. package/src/daemon.ts +174 -38
  6. package/src/domain.test.ts +62 -0
  7. package/src/domain.ts +16 -0
  8. package/src/localServer.ts +114 -128
  9. package/src/mcp.ts +172 -0
  10. package/src/motelClient.ts +166 -14
  11. package/src/registry.ts +26 -23
  12. package/src/runtime.ts +8 -2
  13. package/src/server.ts +10 -9
  14. package/src/services/AsyncIngest.ts +68 -0
  15. package/src/services/TelemetryStore.ts +262 -119
  16. package/src/services/TraceQueryService.ts +3 -1
  17. package/src/services/ingestRpc.ts +41 -0
  18. package/src/services/telemetryWorker.ts +62 -0
  19. package/src/storybook/aiChatStory.tsx +244 -0
  20. package/src/storybook/fixtures/errorState.ts +44 -0
  21. package/src/storybook/fixtures/imagePaste.ts +34 -0
  22. package/src/storybook/fixtures/index.ts +62 -0
  23. package/src/storybook/fixtures/kitchenSink.ts +148 -0
  24. package/src/storybook/fixtures/rawPrompt.ts +15 -0
  25. package/src/storybook/fixtures/short.ts +27 -0
  26. package/src/storybook/fixtures/toolHeavy.ts +65 -0
  27. package/src/telemetry.test.ts +28 -0
  28. package/src/ui/AiChatView.tsx +308 -0
  29. package/src/ui/SpanContentView.tsx +181 -0
  30. package/src/ui/SpanDetail.tsx +98 -17
  31. package/src/ui/TraceDetailsPane.tsx +11 -28
  32. package/src/ui/Waterfall.tsx +43 -148
  33. package/src/ui/aiChatModel.test.ts +391 -0
  34. package/src/ui/aiChatModel.ts +773 -0
  35. package/src/ui/aiState.ts +71 -0
  36. package/src/ui/app/TraceWorkspace.tsx +288 -124
  37. package/src/ui/app/useAppLayout.ts +14 -11
  38. package/src/ui/app/useTraceScreenData.ts +174 -40
  39. package/src/ui/atoms.ts +131 -0
  40. package/src/ui/loaders.ts +120 -0
  41. package/src/ui/persistence.ts +41 -0
  42. package/src/ui/primitives.tsx +27 -13
  43. package/src/ui/state.ts +4 -199
  44. package/src/ui/useAttrFilterPicker.ts +63 -23
  45. package/src/ui/useKeyboardNav.ts +552 -364
  46. package/src/ui/waterfallModel.ts +130 -0
  47. package/src/ui/waterfallNav.test.ts +17 -1
  48. package/src/ui/waterfallNav.ts +1 -1
package/src/ui/state.ts CHANGED
@@ -1,199 +1,4 @@
1
- import { Effect } from "effect"
2
- import * as Atom from "effect/unstable/reactivity/Atom"
3
- import { readFileSync } from "node:fs"
4
- import { dirname } from "node:path"
5
- import { config } from "../config.ts"
6
- import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
7
- import { queryRuntime } from "../runtime.ts"
8
- import { LogQueryService } from "../services/LogQueryService.ts"
9
- import { TraceQueryService } from "../services/TraceQueryService.ts"
10
- import type { ThemeName } from "./theme.ts"
11
-
12
- export type LoadStatus = "loading" | "ready" | "error"
13
- export type DetailView = "waterfall" | "span-detail" | "service-logs"
14
-
15
- export interface TraceState {
16
- readonly status: LoadStatus
17
- readonly services: readonly string[]
18
- readonly data: readonly TraceSummaryItem[]
19
- readonly error: string | null
20
- readonly fetchedAt: Date | null
21
- }
22
-
23
- export interface TraceDetailState {
24
- readonly status: LoadStatus
25
- readonly traceId: string | null
26
- readonly data: TraceItem | null
27
- readonly error: string | null
28
- readonly fetchedAt: Date | null
29
- }
30
-
31
- export interface LogState {
32
- readonly status: LoadStatus
33
- readonly traceId: string | null
34
- readonly data: readonly LogItem[]
35
- readonly error: string | null
36
- readonly fetchedAt: Date | null
37
- }
38
-
39
- export interface ServiceLogState {
40
- readonly status: LoadStatus
41
- readonly serviceName: string | null
42
- readonly data: readonly LogItem[]
43
- readonly error: string | null
44
- readonly fetchedAt: Date | null
45
- }
46
-
47
- export const initialTraceState: TraceState = {
48
- status: "loading",
49
- services: [],
50
- data: [],
51
- error: null,
52
- fetchedAt: null,
53
- }
54
-
55
- export const initialLogState: LogState = {
56
- status: "ready",
57
- traceId: null,
58
- data: [],
59
- error: null,
60
- fetchedAt: null,
61
- }
62
-
63
- export const initialTraceDetailState: TraceDetailState = {
64
- status: "ready",
65
- traceId: null,
66
- data: null,
67
- error: null,
68
- fetchedAt: null,
69
- }
70
-
71
- export const initialServiceLogState: ServiceLogState = {
72
- status: "ready",
73
- serviceName: null,
74
- data: [],
75
- error: null,
76
- fetchedAt: null,
77
- }
78
-
79
- export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
80
- export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
81
- export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
82
- export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
83
- export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
84
- export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
85
- const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
86
- const readLastService = (): string | null => {
87
- try { return readFileSync(lastServicePath, "utf-8").trim() || null }
88
- catch { return null }
89
- }
90
-
91
- let lastPersistedService = readLastService()
92
-
93
- export const persistSelectedService = (service: string) => {
94
- if (service === lastPersistedService) return
95
- lastPersistedService = service
96
- Bun.write(lastServicePath, service).catch(() => {})
97
- }
98
-
99
- export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
100
- export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
101
- export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
102
- export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
103
- export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
104
- export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
105
- export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
106
- export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
107
- export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
108
-
109
- // Waterfall-scoped filter: the `/` key while drilled into a trace
110
- // (viewLevel >= 1) opens this filter instead of the trace-list one.
111
- // Purely client-side — dims spans whose operation name and attribute
112
- // values don't contain the needle.
113
- export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
114
- export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
115
-
116
- // Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
117
- export type AttrPickerMode = "off" | "keys" | "values"
118
- export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
119
- export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
120
- export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
121
-
122
- export interface AttrFacetState {
123
- readonly status: LoadStatus
124
- readonly key: string | null // null when loading keys; set when loading values
125
- readonly data: readonly { readonly value: string; readonly count: number }[]
126
- readonly error: string | null
127
- }
128
-
129
- export const initialAttrFacetState: AttrFacetState = {
130
- status: "ready",
131
- key: null,
132
- data: [],
133
- error: null,
134
- }
135
-
136
- export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
137
-
138
- // Applied filter (drives trace list query)
139
- export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
140
- export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
141
-
142
- const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
143
- const readLastTheme = (): ThemeName => {
144
- try {
145
- const raw = readFileSync(lastThemePath, "utf-8").trim()
146
- return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
147
- } catch {
148
- return "motel-default"
149
- }
150
- }
151
-
152
- let lastPersistedTheme = readLastTheme()
153
-
154
- export const persistSelectedTheme = (theme: ThemeName) => {
155
- if (theme === lastPersistedTheme) return
156
- lastPersistedTheme = theme
157
- Bun.write(lastThemePath, theme).catch(() => {})
158
- }
159
-
160
- export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
161
-
162
- export type TraceSortMode = "recent" | "slowest" | "errors"
163
- export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
164
- export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
165
-
166
- export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
167
- export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
168
- /**
169
- * Server-side trace summary search. Accepts any combination of:
170
- *
171
- * - `attributeFilters` — exact-match span attributes (from the `f` picker)
172
- * - `aiText` — FTS5-backed search across LLM prompt/response
173
- * content (AI_FTS_KEYS), from the `:ai <query>`
174
- * modifier in the `/` filter
175
- *
176
- * Both filters compose: when both are set, a trace must match both. When
177
- * neither is set, callers should prefer `loadRecentTraceSummaries` so
178
- * the server can skip the search path entirely.
179
- */
180
- export const loadFilteredTraceSummaries = (
181
- serviceName: string,
182
- options: {
183
- readonly attributeFilters?: Readonly<Record<string, string>>
184
- readonly aiText?: string | null
185
- },
186
- ) =>
187
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
188
- serviceName,
189
- attributeFilters: options.attributeFilters,
190
- aiText: options.aiText ?? null,
191
- limit: config.otel.traceFetchLimit,
192
- })))
193
- export const loadTraceAttributeKeys = (serviceName: string) =>
194
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
195
- export const loadTraceAttributeValues = (serviceName: string, key: string) =>
196
- queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_values", serviceName, key, limit: 200 })))
197
- export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
198
- export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
199
- export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
1
+ export * from "./atoms.ts"
2
+ export * from "./persistence.ts"
3
+ export * from "./loaders.ts"
4
+ export * from "./aiState.ts"
@@ -3,16 +3,22 @@ import { useEffect } from "react"
3
3
  import {
4
4
  attrFacetStateAtom,
5
5
  attrPickerModeAtom,
6
+ ensureTraceAttributeKeys,
7
+ ensureTraceAttributeValues,
8
+ getCachedFacetKeys,
9
+ getCachedFacetValues,
6
10
  initialAttrFacetState,
7
- loadTraceAttributeKeys,
8
- loadTraceAttributeValues,
9
11
  selectedTraceServiceAtom,
10
12
  } from "./state.ts"
11
13
 
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.
14
+ // Drive the picker's data state from (pickerMode, service, selectedKey).
15
+ //
16
+ // Strategy: stale-while-revalidate. On reopen we publish whatever the
17
+ // module-level cache has instantly (no "loading…" flash), then kick off a
18
+ // background revalidation. The first time we see a (service, key) tuple
19
+ // we still show `loading` so the UI has something to say. The module-level
20
+ // caches in `state.ts` mean a service-change pre-warm can fill the cache
21
+ // before the user ever presses `f`.
16
22
  export const useAttrFilterPicker = (selectedKey: string | null) => {
17
23
  const [pickerMode] = useAtom(attrPickerModeAtom)
18
24
  const [service] = useAtom(selectedTraceServiceAtom)
@@ -24,24 +30,58 @@ export const useAttrFilterPicker = (selectedKey: string | null) => {
24
30
  return
25
31
  }
26
32
  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
- }
33
+ const publishReady = (key: string | null, data: readonly { readonly value: string; readonly count: number }[]) => {
34
+ setFacetState({ status: "ready", key, data, error: null })
35
+ }
36
+ const publishLoading = (key: string | null, previous: readonly { readonly value: string; readonly count: number }[] = []) => {
37
+ setFacetState({ status: "loading", key, data: previous, error: null })
41
38
  }
42
- void load()
43
- return () => {
44
- cancelled = true
39
+ const publishError = (key: string | null, previous: readonly { readonly value: string; readonly count: number }[], err: unknown) => {
40
+ setFacetState({
41
+ status: "error",
42
+ key,
43
+ data: previous,
44
+ error: err instanceof Error ? err.message : String(err),
45
+ })
45
46
  }
47
+
48
+ if (pickerMode === "keys") {
49
+ const cached = getCachedFacetKeys(service)
50
+ if (cached) {
51
+ publishReady(null, cached.data)
52
+ } else {
53
+ publishLoading(null)
54
+ }
55
+ ensureTraceAttributeKeys(service)
56
+ .then((entry) => {
57
+ if (cancelled) return
58
+ publishReady(null, entry.data)
59
+ })
60
+ .catch((err) => {
61
+ if (cancelled) return
62
+ publishError(null, cached?.data ?? [], err)
63
+ })
64
+ } else if (selectedKey) {
65
+ const cached = getCachedFacetValues(service, selectedKey)
66
+ if (cached) {
67
+ publishReady(selectedKey, cached.data)
68
+ } else {
69
+ publishLoading(selectedKey)
70
+ }
71
+ ensureTraceAttributeValues(service, selectedKey)
72
+ .then((entry) => {
73
+ if (cancelled) return
74
+ publishReady(selectedKey, entry.data)
75
+ })
76
+ .catch((err) => {
77
+ if (cancelled) return
78
+ publishError(selectedKey, cached?.data ?? [], err)
79
+ })
80
+ } else {
81
+ // values mode with no key yet — just show empty state.
82
+ publishReady(null, [])
83
+ }
84
+
85
+ return () => { cancelled = true }
46
86
  }, [pickerMode, service, selectedKey, setFacetState])
47
87
  }