@kitlangton/motel 0.1.3 → 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 +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -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 +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -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 +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- 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 +61 -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 +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- 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 +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- package/src/ui/waterfallFilter.test.ts +84 -0
- package/src/ui/waterfallFilter.ts +59 -0
- package/src/ui/waterfallModel.ts +130 -0
- package/src/ui/waterfallNav.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { config } from "../config.ts"
|
|
3
|
+
import { queryRuntime } from "../runtime.ts"
|
|
4
|
+
import { LogQueryService } from "../services/LogQueryService.ts"
|
|
5
|
+
import { TraceQueryService } from "../services/TraceQueryService.ts"
|
|
6
|
+
|
|
7
|
+
export const loadTraceServices = () =>
|
|
8
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
|
|
9
|
+
|
|
10
|
+
export const loadRecentTraceSummaries = (serviceName: string) =>
|
|
11
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Server-side trace summary search. Accepts any combination of:
|
|
15
|
+
*
|
|
16
|
+
* - `attributeFilters` — exact-match span attributes (from the `f` picker)
|
|
17
|
+
* - `aiText` — FTS5-backed search across LLM prompt/response
|
|
18
|
+
* content (AI_FTS_KEYS), from the `:ai <query>`
|
|
19
|
+
* modifier in the `/` filter
|
|
20
|
+
*
|
|
21
|
+
* Both filters compose: when both are set, a trace must match both. When
|
|
22
|
+
* neither is set, callers should prefer `loadRecentTraceSummaries` so
|
|
23
|
+
* the server can skip the search path entirely.
|
|
24
|
+
*/
|
|
25
|
+
export const loadFilteredTraceSummaries = (
|
|
26
|
+
serviceName: string,
|
|
27
|
+
options: {
|
|
28
|
+
readonly attributeFilters?: Readonly<Record<string, string>>
|
|
29
|
+
readonly aiText?: string | null
|
|
30
|
+
},
|
|
31
|
+
) =>
|
|
32
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.searchTraceSummaries({
|
|
33
|
+
serviceName,
|
|
34
|
+
attributeFilters: options.attributeFilters,
|
|
35
|
+
aiText: options.aiText ?? null,
|
|
36
|
+
limit: config.otel.traceFetchLimit,
|
|
37
|
+
})))
|
|
38
|
+
|
|
39
|
+
export const loadTraceAttributeKeys = (serviceName: string) =>
|
|
40
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listFacets({ type: "traces", field: "attribute_keys", serviceName, limit: 200 })))
|
|
41
|
+
|
|
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 })))
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Facet cache (drives the `f` attribute filter picker)
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
export interface FacetRow {
|
|
50
|
+
readonly value: string
|
|
51
|
+
readonly count: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface FacetCacheEntry {
|
|
55
|
+
readonly data: readonly FacetRow[]
|
|
56
|
+
readonly fetchedAt: Date
|
|
57
|
+
}
|
|
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>>()
|
|
63
|
+
|
|
64
|
+
const valuesKey = (service: string, key: string) => `${service}\u0000${key}`
|
|
65
|
+
|
|
66
|
+
export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
|
|
67
|
+
facetKeysCache.get(service) ?? null
|
|
68
|
+
|
|
69
|
+
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
|
+
}
|
|
87
|
+
|
|
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
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Called from the refreshNonce effect alongside the trace / log cache clears. */
|
|
106
|
+
export const invalidateFacetCaches = () => {
|
|
107
|
+
facetKeysCache.clear()
|
|
108
|
+
facetValuesCache.clear()
|
|
109
|
+
facetKeysInflight.clear()
|
|
110
|
+
facetValuesInflight.clear()
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export const loadTraceDetail = (traceId: string) =>
|
|
114
|
+
queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
|
|
115
|
+
|
|
116
|
+
export const loadTraceLogs = (traceId: string) =>
|
|
117
|
+
queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
|
|
118
|
+
|
|
119
|
+
export const loadServiceLogs = (serviceName: string) =>
|
|
120
|
+
queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs"
|
|
2
|
+
import { dirname } from "node:path"
|
|
3
|
+
import { config } from "../config.ts"
|
|
4
|
+
import type { ThemeName } from "./theme.ts"
|
|
5
|
+
|
|
6
|
+
const lastServicePath = `${dirname(config.otel.databasePath)}/last-service.txt`
|
|
7
|
+
|
|
8
|
+
export const readLastService = (): string | null => {
|
|
9
|
+
try {
|
|
10
|
+
return readFileSync(lastServicePath, "utf-8").trim() || null
|
|
11
|
+
} catch {
|
|
12
|
+
return null
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let lastPersistedService = readLastService()
|
|
17
|
+
|
|
18
|
+
export const persistSelectedService = (service: string) => {
|
|
19
|
+
if (service === lastPersistedService) return
|
|
20
|
+
lastPersistedService = service
|
|
21
|
+
Bun.write(lastServicePath, service).catch(() => {})
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
25
|
+
|
|
26
|
+
export const readLastTheme = (): ThemeName => {
|
|
27
|
+
try {
|
|
28
|
+
const raw = readFileSync(lastThemePath, "utf-8").trim()
|
|
29
|
+
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
|
|
30
|
+
} catch {
|
|
31
|
+
return "motel-default"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let lastPersistedTheme = readLastTheme()
|
|
36
|
+
|
|
37
|
+
export const persistSelectedTheme = (theme: ThemeName) => {
|
|
38
|
+
if (theme === lastPersistedTheme) return
|
|
39
|
+
lastPersistedTheme = theme
|
|
40
|
+
Bun.write(lastThemePath, theme).catch(() => {})
|
|
41
|
+
}
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { Children } from "react"
|
|
2
3
|
import { colors } from "./theme.ts"
|
|
3
4
|
import { fitCell, truncateText } from "./format.ts"
|
|
4
5
|
import type { DetailView } from "./state.ts"
|
|
@@ -19,19 +20,32 @@ export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: stri
|
|
|
19
20
|
</box>
|
|
20
21
|
)
|
|
21
22
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
23
|
+
const collapseFormattingWhitespace = (children: React.ReactNode) => Children.toArray(children).filter((child) => {
|
|
24
|
+
if (typeof child !== "string") return true
|
|
25
|
+
if (child.trim().length > 0) return true
|
|
26
|
+
// OpenTUI preserves JSX indentation/newline text nodes, so strip the
|
|
27
|
+
// formatting-only whitespace between inline spans while keeping any
|
|
28
|
+
// intentional in-band spaces that callers render explicitly.
|
|
29
|
+
return !/[\r\n\t]/.test(child)
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => {
|
|
33
|
+
const inlineChildren = collapseFormattingWhitespace(children)
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<box height={1}>
|
|
37
|
+
{bg ? (
|
|
38
|
+
<text wrapMode="none" truncate fg={fg} bg={bg}>
|
|
39
|
+
{inlineChildren}
|
|
40
|
+
</text>
|
|
41
|
+
) : (
|
|
42
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
43
|
+
{inlineChildren}
|
|
44
|
+
</text>
|
|
45
|
+
)}
|
|
46
|
+
</box>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
35
49
|
|
|
36
50
|
export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
|
|
37
51
|
const availableRightWidth = Math.max(8, width - left.length - 2)
|
|
@@ -80,13 +94,25 @@ export const SeparatorColumn = ({ height, junctionChars }: { height: number; jun
|
|
|
80
94
|
)
|
|
81
95
|
}
|
|
82
96
|
|
|
83
|
-
export const FilterBar = ({ text, width }: { text: string; width: number }) =>
|
|
84
|
-
<
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
97
|
+
export const FilterBar = ({ text, width }: { text: string; width: number }) => {
|
|
98
|
+
// Layout: "/<text>█ op name · :error · :ai <query>"
|
|
99
|
+
// Cursor block sits immediately after the typed text (no padding —
|
|
100
|
+
// fitCell would pad the empty string to fill the available width and
|
|
101
|
+
// push the cursor to the far right, which looked like a bug). The
|
|
102
|
+
// hint only renders while the input is empty so real typing never
|
|
103
|
+
// collides with it.
|
|
104
|
+
const hint = text.length === 0 ? " op name · :error · :ai <query>" : ""
|
|
105
|
+
const textMaxWidth = Math.max(1, width - 2 - hint.length)
|
|
106
|
+
const displayText = text.length > textMaxWidth ? text.slice(text.length - textMaxWidth) : text
|
|
107
|
+
return (
|
|
108
|
+
<TextLine fg={colors.accent}>
|
|
109
|
+
<span fg={colors.muted}>{"/"}</span>
|
|
110
|
+
<span fg={colors.text}>{displayText}</span>
|
|
111
|
+
<span fg={colors.accent}>{"\u2588"}</span>
|
|
112
|
+
{hint ? <span fg={colors.muted}>{hint}</span> : null}
|
|
113
|
+
</TextLine>
|
|
114
|
+
)
|
|
115
|
+
}
|
|
90
116
|
|
|
91
117
|
const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
|
|
92
118
|
|
|
@@ -118,7 +144,7 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
|
|
|
118
144
|
{row("t", `cycle theme (${themeLabel})`)}
|
|
119
145
|
{row("tab", "toggle service logs")}
|
|
120
146
|
{row("[ ]", "switch service")}
|
|
121
|
-
{row("/", "filter by root operation")}
|
|
147
|
+
{row("/", "filter by root operation (\u2003:error, :ai <query>)")}
|
|
122
148
|
{row("f", "filter traces by span attribute")}
|
|
123
149
|
{row("s", "cycle sort mode")}
|
|
124
150
|
{row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
|
package/src/ui/state.ts
CHANGED
|
@@ -1,169 +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
|
-
// 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
|
-
|
|
135
|
-
const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
136
|
-
const readLastTheme = (): ThemeName => {
|
|
137
|
-
try {
|
|
138
|
-
const raw = readFileSync(lastThemePath, "utf-8").trim()
|
|
139
|
-
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
|
|
140
|
-
} catch {
|
|
141
|
-
return "motel-default"
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
let lastPersistedTheme = readLastTheme()
|
|
146
|
-
|
|
147
|
-
export const persistSelectedTheme = (theme: ThemeName) => {
|
|
148
|
-
if (theme === lastPersistedTheme) return
|
|
149
|
-
lastPersistedTheme = theme
|
|
150
|
-
Bun.write(lastThemePath, theme).catch(() => {})
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
|
|
154
|
-
|
|
155
|
-
export type TraceSortMode = "recent" | "slowest" | "errors"
|
|
156
|
-
export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
|
|
157
|
-
export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
|
|
158
|
-
|
|
159
|
-
export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
|
|
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 })))
|
|
167
|
-
export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
|
|
168
|
-
export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
|
|
169
|
-
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
|
}
|