@aryasaatvik/motel 0.3.0
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 +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -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 +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- 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.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
package/src/ui/atoms.ts
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as Atom from "effect/unstable/reactivity/Atom"
|
|
2
|
+
import { config } from "../config.ts"
|
|
3
|
+
import type { LogItem, TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
4
|
+
import type { ThemeName } from "./theme.ts"
|
|
5
|
+
import { readLastService, readLastTheme } from "./persistence.ts"
|
|
6
|
+
|
|
7
|
+
export type LoadStatus = "loading" | "ready" | "error"
|
|
8
|
+
export type DetailView = "waterfall" | "span-detail" | "service-logs"
|
|
9
|
+
|
|
10
|
+
export interface TraceState {
|
|
11
|
+
readonly status: LoadStatus
|
|
12
|
+
readonly services: readonly string[]
|
|
13
|
+
readonly data: readonly TraceSummaryItem[]
|
|
14
|
+
readonly error: string | null
|
|
15
|
+
readonly fetchedAt: Date | null
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface TraceDetailState {
|
|
19
|
+
readonly status: LoadStatus
|
|
20
|
+
readonly traceId: string | null
|
|
21
|
+
readonly data: TraceItem | null
|
|
22
|
+
readonly error: string | null
|
|
23
|
+
readonly fetchedAt: Date | null
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface LogState {
|
|
27
|
+
readonly status: LoadStatus
|
|
28
|
+
readonly traceId: string | null
|
|
29
|
+
readonly data: readonly LogItem[]
|
|
30
|
+
readonly error: string | null
|
|
31
|
+
readonly fetchedAt: Date | null
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ServiceLogState {
|
|
35
|
+
readonly status: LoadStatus
|
|
36
|
+
readonly serviceName: string | null
|
|
37
|
+
readonly data: readonly LogItem[]
|
|
38
|
+
readonly error: string | null
|
|
39
|
+
readonly fetchedAt: Date | null
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const initialTraceState: TraceState = {
|
|
43
|
+
status: "loading",
|
|
44
|
+
services: [],
|
|
45
|
+
data: [],
|
|
46
|
+
error: null,
|
|
47
|
+
fetchedAt: null,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const initialLogState: LogState = {
|
|
51
|
+
status: "ready",
|
|
52
|
+
traceId: null,
|
|
53
|
+
data: [],
|
|
54
|
+
error: null,
|
|
55
|
+
fetchedAt: null,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export const initialTraceDetailState: TraceDetailState = {
|
|
59
|
+
status: "ready",
|
|
60
|
+
traceId: null,
|
|
61
|
+
data: null,
|
|
62
|
+
error: null,
|
|
63
|
+
fetchedAt: null,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export const initialServiceLogState: ServiceLogState = {
|
|
67
|
+
status: "ready",
|
|
68
|
+
serviceName: null,
|
|
69
|
+
data: [],
|
|
70
|
+
error: null,
|
|
71
|
+
fetchedAt: null,
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const traceStateAtom = Atom.make(initialTraceState).pipe(Atom.keepAlive)
|
|
75
|
+
export const traceDetailStateAtom = Atom.make(initialTraceDetailState).pipe(Atom.keepAlive)
|
|
76
|
+
export const logStateAtom = Atom.make(initialLogState).pipe(Atom.keepAlive)
|
|
77
|
+
export const serviceLogStateAtom = Atom.make(initialServiceLogState).pipe(Atom.keepAlive)
|
|
78
|
+
export const selectedServiceLogIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
79
|
+
export const selectedTraceIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
80
|
+
export const selectedTraceServiceAtom = Atom.make<string | null>(readLastService() ?? config.otel.serviceName).pipe(Atom.keepAlive)
|
|
81
|
+
export const refreshNonceAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
82
|
+
export const noticeAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
83
|
+
export const selectedSpanIndexAtom = Atom.make<number | null>(null).pipe(Atom.keepAlive)
|
|
84
|
+
// Cursor inside the full-screen span content view (detailView === "span-detail").
|
|
85
|
+
// Tracks which span tag is currently selected for copy / drill-in. Reset to 0
|
|
86
|
+
// on each new span so the cursor doesn't point past a shorter tag list.
|
|
87
|
+
export const selectedAttrIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
88
|
+
export const detailViewAtom = Atom.make<DetailView>("waterfall").pipe(Atom.keepAlive)
|
|
89
|
+
export const showHelpAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
90
|
+
export const autoRefreshAtom = Atom.make(true).pipe(Atom.keepAlive)
|
|
91
|
+
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
92
|
+
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
93
|
+
|
|
94
|
+
// Waterfall-scoped filter: the `/` key while drilled into a trace
|
|
95
|
+
// (viewLevel >= 1) opens this filter instead of the trace-list one.
|
|
96
|
+
// Purely client-side — dims spans whose operation name and attribute
|
|
97
|
+
// values don't contain the needle.
|
|
98
|
+
export const waterfallFilterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
99
|
+
export const waterfallFilterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
100
|
+
|
|
101
|
+
// Attribute filter (F key): pick a span-attribute key + exact value to restrict the trace list.
|
|
102
|
+
export type AttrPickerMode = "off" | "keys" | "values"
|
|
103
|
+
export const attrPickerModeAtom = Atom.make<AttrPickerMode>("off").pipe(Atom.keepAlive)
|
|
104
|
+
export const attrPickerInputAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
105
|
+
export const attrPickerIndexAtom = Atom.make(0).pipe(Atom.keepAlive)
|
|
106
|
+
|
|
107
|
+
export interface AttrFacetState {
|
|
108
|
+
readonly status: LoadStatus
|
|
109
|
+
readonly key: string | null
|
|
110
|
+
readonly data: readonly { readonly value: string; readonly count: number }[]
|
|
111
|
+
readonly error: string | null
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export const initialAttrFacetState: AttrFacetState = {
|
|
115
|
+
status: "ready",
|
|
116
|
+
key: null,
|
|
117
|
+
data: [],
|
|
118
|
+
error: null,
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const attrFacetStateAtom = Atom.make(initialAttrFacetState).pipe(Atom.keepAlive)
|
|
122
|
+
|
|
123
|
+
// Applied filter (drives trace list query)
|
|
124
|
+
export const activeAttrKeyAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
125
|
+
export const activeAttrValueAtom = Atom.make<string | null>(null).pipe(Atom.keepAlive)
|
|
126
|
+
|
|
127
|
+
export const selectedThemeAtom = Atom.make<ThemeName>(readLastTheme()).pipe(Atom.keepAlive)
|
|
128
|
+
|
|
129
|
+
export type TraceSortMode = "recent" | "slowest" | "errors"
|
|
130
|
+
export const traceSortAtom = Atom.make<TraceSortMode>("recent").pipe(Atom.keepAlive)
|
|
131
|
+
export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<string>).pipe(Atom.keepAlive)
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, it } from "bun:test"
|
|
2
|
+
import { parseFilterText } from "./filterParser.ts"
|
|
3
|
+
|
|
4
|
+
describe("parseFilterText", () => {
|
|
5
|
+
it("returns empty state for empty input", () => {
|
|
6
|
+
expect(parseFilterText("")).toEqual({ aiText: null, errorOnly: false, operationNeedle: "" })
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it("extracts a bare operation-name needle", () => {
|
|
10
|
+
expect(parseFilterText("streamText")).toEqual({
|
|
11
|
+
aiText: null, errorOnly: false, operationNeedle: "streamtext",
|
|
12
|
+
})
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("recognizes :error modifier", () => {
|
|
16
|
+
expect(parseFilterText(":error")).toEqual({
|
|
17
|
+
aiText: null, errorOnly: true, operationNeedle: "",
|
|
18
|
+
})
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it("composes :error with an operation needle", () => {
|
|
22
|
+
expect(parseFilterText("llm :error")).toEqual({
|
|
23
|
+
aiText: null, errorOnly: true, operationNeedle: "llm",
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it("extracts :ai query up to end of string", () => {
|
|
28
|
+
expect(parseFilterText(":ai rate limit")).toEqual({
|
|
29
|
+
aiText: "rate limit", errorOnly: false, operationNeedle: "",
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it("extracts :ai query stopping at the next modifier", () => {
|
|
34
|
+
expect(parseFilterText(":ai tool_use :error")).toEqual({
|
|
35
|
+
aiText: "tool_use", errorOnly: true, operationNeedle: "",
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it("composes operation needle + :ai + :error", () => {
|
|
40
|
+
expect(parseFilterText("stream :ai rate :error")).toEqual({
|
|
41
|
+
aiText: "rate", errorOnly: true, operationNeedle: "stream",
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it("ignores :ai with empty query", () => {
|
|
46
|
+
expect(parseFilterText(":ai ")).toEqual({
|
|
47
|
+
aiText: null, errorOnly: false, operationNeedle: "",
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it("is case-insensitive for modifiers", () => {
|
|
52
|
+
expect(parseFilterText(":AI foo :ERROR")).toEqual({
|
|
53
|
+
aiText: "foo", errorOnly: true, operationNeedle: "",
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parses the `/` filter input in the trace list into its component
|
|
3
|
+
* modifiers. Supports composable tokens:
|
|
4
|
+
*
|
|
5
|
+
* - `:error` — restricts to traces with at least one failed span
|
|
6
|
+
* - `:ai <query...>` — FTS-backed search against LLM prompt/response
|
|
7
|
+
* content (AI_FTS_KEYS) across every span in the
|
|
8
|
+
* trace. The query runs up to the next `:modifier`
|
|
9
|
+
* or end of input, so `"/ :ai rate limit :error"`
|
|
10
|
+
* passes `"rate limit"` as the aiText and also
|
|
11
|
+
* sets errorOnly.
|
|
12
|
+
* - bare text — case-insensitive substring match against the
|
|
13
|
+
* trace's root operation name (client-side)
|
|
14
|
+
*
|
|
15
|
+
* Keep this a pure function: it's unit-tested separately and the React
|
|
16
|
+
* hook just calls it per render.
|
|
17
|
+
*/
|
|
18
|
+
export interface ParsedFilter {
|
|
19
|
+
readonly aiText: string | null
|
|
20
|
+
readonly errorOnly: boolean
|
|
21
|
+
readonly operationNeedle: string
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export const parseFilterText = (raw: string): ParsedFilter => {
|
|
25
|
+
const text = raw ?? ""
|
|
26
|
+
|
|
27
|
+
// `:ai <query>` — greedy up to the next `:` modifier or end. The
|
|
28
|
+
// leading `(^|\s)` guard prevents matching a stray `:ai` glued to
|
|
29
|
+
// non-space characters (shouldn't happen in practice but cheap).
|
|
30
|
+
const aiMatch = text.match(/(?:^|\s):ai\s+([^:]*?)(?=\s:|$)/i)
|
|
31
|
+
const aiText = aiMatch?.[1]?.trim() || null
|
|
32
|
+
|
|
33
|
+
const errorOnly = /(?:^|\s):error(?=\s|$)/i.test(text)
|
|
34
|
+
|
|
35
|
+
// Whatever remains after removing the recognized modifiers becomes the
|
|
36
|
+
// operation-name needle. Lowercased here so the call site can do a
|
|
37
|
+
// plain includes() without re-lowercasing.
|
|
38
|
+
const operationNeedle = text
|
|
39
|
+
.replace(/(?:^|\s):ai\s+[^:]*?(?=\s:|$)/i, " ")
|
|
40
|
+
.replace(/(?:^|\s):error(?=\s|$)/i, " ")
|
|
41
|
+
.trim()
|
|
42
|
+
.toLowerCase()
|
|
43
|
+
|
|
44
|
+
return { aiText, errorOnly, operationNeedle }
|
|
45
|
+
}
|
package/src/ui/format.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { resolveOtelUrl } from "../config.ts"
|
|
2
|
+
import type { LogItem } from "../domain.ts"
|
|
3
|
+
import { colors } from "./theme.ts"
|
|
4
|
+
|
|
5
|
+
export const truncateText = (text: string, width: number) => {
|
|
6
|
+
if (width <= 0) return ""
|
|
7
|
+
if (text.length <= width) return text
|
|
8
|
+
if (width <= 3) return text.slice(0, width)
|
|
9
|
+
return `${text.slice(0, width - 3)}...`
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const fitCell = (text: string, width: number, align: "left" | "right" = "left") => {
|
|
13
|
+
const trimmed = truncateText(text, width)
|
|
14
|
+
return align === "right" ? trimmed.padStart(width, " ") : trimmed.padEnd(width, " ")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const formatShortDate = (date: Date) => date.toLocaleDateString("en-US", { month: "numeric", day: "numeric" })
|
|
18
|
+
|
|
19
|
+
export const formatTimestamp = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase()
|
|
20
|
+
|
|
21
|
+
export const formatDuration = (durationMs: number) => {
|
|
22
|
+
const { number, unit } = splitDuration(durationMs)
|
|
23
|
+
return `${number}${unit}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Split a duration into its numeric and unit parts so the unit can render
|
|
28
|
+
* dimmer than the number (easier to visually parse a column of durations).
|
|
29
|
+
*/
|
|
30
|
+
export const splitDuration = (durationMs: number): { number: string; unit: "s" | "ms" } => {
|
|
31
|
+
const trimDecimal = (value: string) => value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")
|
|
32
|
+
|
|
33
|
+
if (durationMs >= 10_000) return { number: `${Math.round(durationMs / 1000)}`, unit: "s" }
|
|
34
|
+
if (durationMs >= 1000) return { number: trimDecimal((durationMs / 1000).toFixed(1)), unit: "s" }
|
|
35
|
+
if (durationMs >= 100) return { number: `${Math.round(durationMs)}`, unit: "ms" }
|
|
36
|
+
if (durationMs >= 10) return { number: trimDecimal(durationMs.toFixed(1)), unit: "ms" }
|
|
37
|
+
return { number: trimDecimal(durationMs.toFixed(2)), unit: "ms" }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const lifecycleLabel = (value: { readonly isRunning: boolean }) => (value.isRunning ? "open" : "closed")
|
|
41
|
+
|
|
42
|
+
export const relativeTime = (date: Date) => {
|
|
43
|
+
const seconds = Math.max(0, Math.floor((Date.now() - date.getTime()) / 1000))
|
|
44
|
+
if (seconds < 60) return `${seconds}s`
|
|
45
|
+
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
|
|
46
|
+
if (seconds < 86_400) return `${Math.floor(seconds / 3600)}h`
|
|
47
|
+
return `${Math.floor(seconds / 86_400)}d`
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const formatLogTimestamp = (timestamp: Date) => `${formatShortDate(timestamp)} ${formatTimestamp(timestamp)}`
|
|
51
|
+
|
|
52
|
+
export const logHeadline = (body: string) => body.split(/\r?\n/, 1)[0]?.replace(/\s+/g, " ").trim() || ""
|
|
53
|
+
|
|
54
|
+
export const wrapTextLines = (text: string, width: number, maxLines: number) => {
|
|
55
|
+
const normalized = text.replace(/\r/g, "")
|
|
56
|
+
const hardLines = normalized.split("\n")
|
|
57
|
+
const lines: string[] = []
|
|
58
|
+
|
|
59
|
+
for (const hardLine of hardLines) {
|
|
60
|
+
let remaining = hardLine
|
|
61
|
+
if (remaining.length === 0) {
|
|
62
|
+
lines.push("")
|
|
63
|
+
if (lines.length >= maxLines) return lines
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
while (remaining.length > 0) {
|
|
67
|
+
lines.push(remaining.slice(0, width))
|
|
68
|
+
remaining = remaining.slice(width)
|
|
69
|
+
if (lines.length >= maxLines) {
|
|
70
|
+
if (remaining.length > 0) {
|
|
71
|
+
lines[maxLines - 1] = truncateText(lines[maxLines - 1]!, width)
|
|
72
|
+
}
|
|
73
|
+
return lines
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return lines.slice(0, maxLines)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export const traceIndicator = (trace: { readonly errorCount: number }) => (trace.errorCount > 0 ? "!" : "\u00b7")
|
|
82
|
+
export const traceIndicatorColor = (trace: { readonly errorCount: number }) => (trace.errorCount > 0 ? colors.error : colors.passing)
|
|
83
|
+
export const traceRowId = (traceId: string) => `trace-row-${traceId}`
|
|
84
|
+
|
|
85
|
+
export const logSeverityColor = (severity: string) => {
|
|
86
|
+
if (severity.startsWith("ERROR") || severity.startsWith("FATAL")) return colors.error
|
|
87
|
+
if (severity.startsWith("WARN")) return colors.warning
|
|
88
|
+
return colors.count
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const relevantLogAttributes = (log: LogItem) =>
|
|
92
|
+
Object.entries(log.attributes).filter(([key]) =>
|
|
93
|
+
![
|
|
94
|
+
"deployment.environment.name",
|
|
95
|
+
"service.instance.id",
|
|
96
|
+
"service.name",
|
|
97
|
+
"telemetry.sdk.name",
|
|
98
|
+
"telemetry.sdk.language",
|
|
99
|
+
"fiberId",
|
|
100
|
+
"spanId",
|
|
101
|
+
"traceId",
|
|
102
|
+
].includes(key),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
export const traceUiUrl = (traceId: string) => resolveOtelUrl(`/trace/${traceId}`)
|
|
106
|
+
export const webUiUrl = () => resolveOtelUrl(`/traces`)
|
|
107
|
+
|
|
108
|
+
export const copyToClipboard = async (value: string) => {
|
|
109
|
+
const proc = Bun.spawn({
|
|
110
|
+
cmd: ["pbcopy"],
|
|
111
|
+
stdin: "pipe",
|
|
112
|
+
stdout: "ignore",
|
|
113
|
+
stderr: "pipe",
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
if (!proc.stdin) {
|
|
117
|
+
throw new Error("Clipboard is not available")
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
proc.stdin.write(value)
|
|
121
|
+
proc.stdin.end()
|
|
122
|
+
|
|
123
|
+
const exitCode = await proc.exited
|
|
124
|
+
if (exitCode !== 0) {
|
|
125
|
+
const stderr = await new Response(proc.stderr).text()
|
|
126
|
+
throw new Error(stderr.trim() || "Could not copy OTEL server details")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { Effect } from "effect"
|
|
2
|
+
import { config } from "../config.ts"
|
|
3
|
+
import { queryRuntime } from "../runtime.ts"
|
|
4
|
+
import { TelemetryStoreReadonly } from "../services/TelemetryStore.ts"
|
|
5
|
+
import { makeCachedLoader } from "./cachedLoader.ts"
|
|
6
|
+
|
|
7
|
+
export const loadTraceServices = () =>
|
|
8
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listServices))
|
|
9
|
+
|
|
10
|
+
export const loadRecentTraceSummaries = (serviceName: string) =>
|
|
11
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (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(TelemetryStoreReadonly, (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(TelemetryStoreReadonly, (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(TelemetryStoreReadonly, (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 wrapWithTimestamp = (data: readonly FacetRow[]): FacetCacheEntry => ({ data, fetchedAt: new Date() })
|
|
60
|
+
|
|
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
|
+
})
|
|
69
|
+
|
|
70
|
+
export const getCachedFacetKeys = (service: string): FacetCacheEntry | null =>
|
|
71
|
+
facetKeysLoader.get(service) ?? null
|
|
72
|
+
|
|
73
|
+
export const getCachedFacetValues = (service: string, key: string): FacetCacheEntry | null =>
|
|
74
|
+
facetValuesLoader.get({ service, key }) ?? null
|
|
75
|
+
|
|
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 })
|
|
87
|
+
|
|
88
|
+
/** Called from the refreshNonce effect alongside the trace / log cache clears. */
|
|
89
|
+
export const invalidateFacetCaches = () => {
|
|
90
|
+
facetKeysLoader.invalidate()
|
|
91
|
+
facetValuesLoader.invalidate()
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export const loadTraceDetail = (traceId: string) =>
|
|
95
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.getTrace(traceId)))
|
|
96
|
+
|
|
97
|
+
export const loadTraceLogs = (traceId: string) =>
|
|
98
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (service) => service.listTraceLogs(traceId)))
|
|
99
|
+
|
|
100
|
+
export const loadServiceLogs = (serviceName: string) =>
|
|
101
|
+
queryRuntime.runPromise(Effect.flatMap(TelemetryStoreReadonly, (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 { defaultThemeName, 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 : defaultThemeName
|
|
30
|
+
} catch {
|
|
31
|
+
return defaultThemeName
|
|
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
|
+
}
|