@kitlangton/motel 0.2.0 → 0.2.1
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/AGENTS.md +5 -0
- package/package.json +5 -3
- package/src/App.tsx +233 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +16 -0
- package/src/localServer.ts +111 -121
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +52 -0
- package/src/services/TelemetryStore.ts +151 -26
- package/src/services/TraceQueryService.ts +3 -1
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +243 -0
- package/src/storybook/fixtures/errorState.ts +44 -0
- package/src/storybook/fixtures/imagePaste.ts +34 -0
- package/src/storybook/fixtures/index.ts +62 -0
- package/src/storybook/fixtures/kitchenSink.ts +148 -0
- package/src/storybook/fixtures/rawPrompt.ts +15 -0
- package/src/storybook/fixtures/short.ts +27 -0
- package/src/storybook/fixtures/toolHeavy.ts +65 -0
- package/src/telemetry.test.ts +28 -0
- package/src/ui/AiChatView.tsx +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +2 -1
- package/src/ui/Waterfall.tsx +38 -138
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +291 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +173 -39
- package/src/ui/atoms.ts +131 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +27 -13
- package/src/ui/state.ts +4 -199
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +552 -364
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
package/src/ui/state.ts
CHANGED
|
@@ -1,199 +1,4 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
}
|