@kitlangton/motel 0.1.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 +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { colors } from "./theme.ts"
|
|
3
|
+
import { fitCell, truncateText } from "./format.ts"
|
|
4
|
+
import type { DetailView } from "./state.ts"
|
|
5
|
+
|
|
6
|
+
export const BlankRow = () => <box height={1} />
|
|
7
|
+
|
|
8
|
+
export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
|
|
9
|
+
<box height={1}>
|
|
10
|
+
{bold ? (
|
|
11
|
+
<text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
|
|
12
|
+
{text}
|
|
13
|
+
</text>
|
|
14
|
+
) : (
|
|
15
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
16
|
+
{text}
|
|
17
|
+
</text>
|
|
18
|
+
)}
|
|
19
|
+
</box>
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
export const TextLine = ({ children, fg = colors.text, bg }: { children: React.ReactNode; fg?: string; bg?: string | undefined }) => (
|
|
23
|
+
<box height={1}>
|
|
24
|
+
{bg ? (
|
|
25
|
+
<text wrapMode="none" truncate fg={fg} bg={bg}>
|
|
26
|
+
{children}
|
|
27
|
+
</text>
|
|
28
|
+
) : (
|
|
29
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
30
|
+
{children}
|
|
31
|
+
</text>
|
|
32
|
+
)}
|
|
33
|
+
</box>
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
|
|
37
|
+
const availableRightWidth = Math.max(8, width - left.length - 2)
|
|
38
|
+
const rightText = truncateText(right, availableRightWidth)
|
|
39
|
+
const gap = Math.max(2, width - left.length - rightText.length)
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<TextLine>
|
|
43
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{left}</span>
|
|
44
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
45
|
+
<span fg={rightFg}>{rightText}</span>
|
|
46
|
+
</TextLine>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export const Divider = ({ width }: { width: number }) => (
|
|
51
|
+
<PlainLine text={"\u2500".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
/** Horizontal divider split into left ─ junction ─ right, using flex row so
|
|
55
|
+
* the junction character lands at exactly the same column as the SeparatorColumn. */
|
|
56
|
+
export const SplitDivider = ({ leftWidth, junction, rightWidth }: { leftWidth: number; junction: string; rightWidth: number }) => (
|
|
57
|
+
<box flexDirection="row" height={1}>
|
|
58
|
+
<box width={leftWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(leftWidth)}</text></box>
|
|
59
|
+
<box width={1}><text fg={colors.separator}>{junction}</text></box>
|
|
60
|
+
<box width={rightWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(rightWidth)}</text></box>
|
|
61
|
+
</box>
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
/** Row index → junction character override. Callers supply exactly the
|
|
65
|
+
* glyph they want at each row so the separator lines up with whatever
|
|
66
|
+
* divider geometry the neighboring panes happen to have:
|
|
67
|
+
* - `\u251c` (├) when only the right pane has a divider at that row
|
|
68
|
+
* - `\u2524` (┤) when only the left pane has a divider at that row
|
|
69
|
+
* - `\u253c` (┼) when both do
|
|
70
|
+
*/
|
|
71
|
+
export const SeparatorColumn = ({ height, junctionChars }: { height: number; junctionChars?: ReadonlyMap<number, string> }) => {
|
|
72
|
+
const lines: string[] = []
|
|
73
|
+
for (let i = 0; i < Math.max(1, height); i++) {
|
|
74
|
+
lines.push(junctionChars?.get(i) ?? "\u2502")
|
|
75
|
+
}
|
|
76
|
+
return (
|
|
77
|
+
<box width={1} height={height} overflow="hidden">
|
|
78
|
+
<text fg={colors.separator}>{lines.join("\n")}</text>
|
|
79
|
+
</box>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const FilterBar = ({ text, width }: { text: string; width: number }) => (
|
|
84
|
+
<TextLine fg={colors.accent}>
|
|
85
|
+
<span fg={colors.muted}>{"/"}</span>
|
|
86
|
+
<span fg={colors.text}>{fitCell(text, width - 2)}</span>
|
|
87
|
+
<span fg={colors.accent}>{"\u2588"}</span>
|
|
88
|
+
</TextLine>
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
|
|
92
|
+
|
|
93
|
+
export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: { width: number; height: number; autoRefresh: boolean; themeLabel: string; onClose: () => void }) => {
|
|
94
|
+
const panelWidth = Math.min(76, Math.max(52, width - 10))
|
|
95
|
+
const left = Math.max(2, Math.floor((width - panelWidth) / 2))
|
|
96
|
+
const top = Math.max(1, Math.floor(height / 5))
|
|
97
|
+
const sectionGap = Math.max(1, panelWidth - 24)
|
|
98
|
+
const row = (key: string, desc: string) => (
|
|
99
|
+
<TextLine>
|
|
100
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>{key.padEnd(11)}</span>
|
|
101
|
+
<span fg={colors.muted}>{desc}</span>
|
|
102
|
+
</TextLine>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
|
|
107
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
|
|
108
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
|
|
109
|
+
<TextLine>
|
|
110
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>Help</span>
|
|
111
|
+
<span fg={colors.muted}>{" ".repeat(sectionGap)}</span>
|
|
112
|
+
<span fg={colors.muted}>esc / enter / ? close</span>
|
|
113
|
+
</TextLine>
|
|
114
|
+
<BlankRow />
|
|
115
|
+
{row("j k ↑ ↓", "move selection")}
|
|
116
|
+
{row("enter", "select spans / open detail / jump from logs")}
|
|
117
|
+
{row("esc", "back out of detail or span selection")}
|
|
118
|
+
{row("t", `cycle theme (${themeLabel})`)}
|
|
119
|
+
{row("tab", "toggle service logs")}
|
|
120
|
+
{row("[ ]", "switch service")}
|
|
121
|
+
{row("/", "filter traces")}
|
|
122
|
+
{row("s", "cycle sort mode")}
|
|
123
|
+
{row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
|
|
124
|
+
{row("r", "refresh traces")}
|
|
125
|
+
{row("y", "copy selected trace/span ids")}
|
|
126
|
+
{row("o / O", "open trace / open web UI")}
|
|
127
|
+
{row("gg / G", "jump to first / last")}
|
|
128
|
+
{row("^d / ^u", "page down / up")}
|
|
129
|
+
{row("q", "quit")}
|
|
130
|
+
</box>
|
|
131
|
+
</box>
|
|
132
|
+
</box>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export const FooterHints = ({ spanNavActive, detailView, autoRefresh, width: _width }: { spanNavActive: boolean; detailView: DetailView; autoRefresh: boolean; width: number }) => {
|
|
137
|
+
const enterAction = detailView === "service-logs"
|
|
138
|
+
? "trace"
|
|
139
|
+
: spanNavActive && detailView === "waterfall"
|
|
140
|
+
? "detail"
|
|
141
|
+
: "spans"
|
|
142
|
+
const escAction = spanNavActive
|
|
143
|
+
? (detailView === "span-detail" ? "back" : "traces")
|
|
144
|
+
: null
|
|
145
|
+
const items: Array<[string, string]> = [
|
|
146
|
+
["j/k", "move"],
|
|
147
|
+
["enter", enterAction],
|
|
148
|
+
...(escAction ? [["esc", escAction] as [string, string]] : []),
|
|
149
|
+
["t", "theme"],
|
|
150
|
+
["tab", "logs"],
|
|
151
|
+
["/", "filter"],
|
|
152
|
+
["s", "sort"],
|
|
153
|
+
["a", autoRefresh ? "live" : "paused"],
|
|
154
|
+
["?", "help"],
|
|
155
|
+
["q", "quit"],
|
|
156
|
+
]
|
|
157
|
+
const renderItems = (items: ReadonlyArray<readonly [string, string]>) => (
|
|
158
|
+
items.flatMap(([key, label], index) => [
|
|
159
|
+
<FooterKey key={`${key}-key`} label={key} />,
|
|
160
|
+
<span key={`${key}-label`} fg={colors.muted}>{` ${label}`}</span>,
|
|
161
|
+
index < items.length - 1 ? <span key={`${key}-sep`} fg={colors.separator}>{" · "}</span> : null,
|
|
162
|
+
])
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<TextLine>
|
|
167
|
+
{renderItems(items)}
|
|
168
|
+
</TextLine>
|
|
169
|
+
)
|
|
170
|
+
}
|
package/src/ui/state.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
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
|
+
const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
110
|
+
const readLastTheme = (): ThemeName => {
|
|
111
|
+
try {
|
|
112
|
+
const raw = readFileSync(lastThemePath, "utf-8").trim()
|
|
113
|
+
return raw === "tokyo-night" || raw === "catppuccin" || raw === "motel-default" ? raw : "motel-default"
|
|
114
|
+
} catch {
|
|
115
|
+
return "motel-default"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
let lastPersistedTheme = readLastTheme()
|
|
120
|
+
|
|
121
|
+
export const persistSelectedTheme = (theme: ThemeName) => {
|
|
122
|
+
if (theme === lastPersistedTheme) return
|
|
123
|
+
lastPersistedTheme = theme
|
|
124
|
+
Bun.write(lastThemePath, theme).catch(() => {})
|
|
125
|
+
}
|
|
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)
|
|
132
|
+
|
|
133
|
+
export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
|
|
134
|
+
export const loadRecentTraceSummaries = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listTraceSummaries(serviceName)))
|
|
135
|
+
export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
|
|
136
|
+
export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
|
|
137
|
+
export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
|
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
export interface ThemeColors {
|
|
2
|
+
readonly screenBg: string
|
|
3
|
+
readonly text: string
|
|
4
|
+
readonly muted: string
|
|
5
|
+
readonly separator: string
|
|
6
|
+
readonly accent: string
|
|
7
|
+
readonly error: string
|
|
8
|
+
readonly selectedBg: string
|
|
9
|
+
readonly warning: string
|
|
10
|
+
readonly selectedText: string
|
|
11
|
+
readonly count: string
|
|
12
|
+
readonly passing: string
|
|
13
|
+
readonly defaultService: string
|
|
14
|
+
readonly footerBg: string
|
|
15
|
+
readonly treeLine: string
|
|
16
|
+
readonly previewKey: string
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface ThemeWaterfallColors {
|
|
20
|
+
readonly bar: string
|
|
21
|
+
readonly barError: string
|
|
22
|
+
readonly barBg: string
|
|
23
|
+
readonly barLane: string
|
|
24
|
+
readonly barSelected: string
|
|
25
|
+
readonly barSelectedError: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ThemeDefinition {
|
|
29
|
+
readonly name: ThemeName
|
|
30
|
+
readonly label: string
|
|
31
|
+
readonly colors: ThemeColors
|
|
32
|
+
readonly waterfall: ThemeWaterfallColors
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const motelDefaultTheme: ThemeDefinition = {
|
|
36
|
+
name: "motel-default",
|
|
37
|
+
label: "Motel Default",
|
|
38
|
+
colors: {
|
|
39
|
+
screenBg: "#1c1b29",
|
|
40
|
+
text: "#ede7da",
|
|
41
|
+
muted: "#9f9788",
|
|
42
|
+
separator: "#6f685d",
|
|
43
|
+
accent: "#f4a51c",
|
|
44
|
+
error: "#f97316",
|
|
45
|
+
selectedBg: "#263044",
|
|
46
|
+
warning: "#facc15",
|
|
47
|
+
selectedText: "#f8fafc",
|
|
48
|
+
count: "#d7c5a1",
|
|
49
|
+
passing: "#7dd3a3",
|
|
50
|
+
defaultService: "#93c5fd",
|
|
51
|
+
footerBg: "#000000",
|
|
52
|
+
treeLine: "#524d45",
|
|
53
|
+
previewKey: "#6a6358",
|
|
54
|
+
},
|
|
55
|
+
waterfall: {
|
|
56
|
+
bar: "#f4a51c",
|
|
57
|
+
barError: "#f97316",
|
|
58
|
+
barBg: "#2a2520",
|
|
59
|
+
barLane: "#4a4338",
|
|
60
|
+
barSelected: "#e8c547",
|
|
61
|
+
barSelectedError: "#ff8c42",
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const tokyoNightTheme: ThemeDefinition = {
|
|
66
|
+
name: "tokyo-night",
|
|
67
|
+
label: "Tokyo Night",
|
|
68
|
+
colors: {
|
|
69
|
+
screenBg: "#1a1b26",
|
|
70
|
+
text: "#c0caf5",
|
|
71
|
+
muted: "#7a88b6",
|
|
72
|
+
separator: "#565f89",
|
|
73
|
+
accent: "#7aa2f7",
|
|
74
|
+
error: "#f7768e",
|
|
75
|
+
selectedBg: "#283457",
|
|
76
|
+
warning: "#e0af68",
|
|
77
|
+
selectedText: "#f8fbff",
|
|
78
|
+
count: "#bb9af7",
|
|
79
|
+
passing: "#9ece6a",
|
|
80
|
+
defaultService: "#73daca",
|
|
81
|
+
footerBg: "#000000",
|
|
82
|
+
treeLine: "#414868",
|
|
83
|
+
previewKey: "#6b739c",
|
|
84
|
+
},
|
|
85
|
+
waterfall: {
|
|
86
|
+
bar: "#7aa2f7",
|
|
87
|
+
barError: "#f7768e",
|
|
88
|
+
barBg: "#1f2335",
|
|
89
|
+
barLane: "#2a3050",
|
|
90
|
+
barSelected: "#bb9af7",
|
|
91
|
+
barSelectedError: "#ff9eaf",
|
|
92
|
+
},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const catppuccinTheme: ThemeDefinition = {
|
|
96
|
+
name: "catppuccin",
|
|
97
|
+
label: "Catppuccin Mocha",
|
|
98
|
+
colors: {
|
|
99
|
+
screenBg: "#11111b",
|
|
100
|
+
text: "#cdd6f4",
|
|
101
|
+
muted: "#a6adc8",
|
|
102
|
+
separator: "#6c7086",
|
|
103
|
+
accent: "#f5c2e7",
|
|
104
|
+
error: "#f38ba8",
|
|
105
|
+
selectedBg: "#313244",
|
|
106
|
+
warning: "#f9e2af",
|
|
107
|
+
selectedText: "#f5f7ff",
|
|
108
|
+
count: "#fab387",
|
|
109
|
+
passing: "#a6e3a1",
|
|
110
|
+
defaultService: "#89dceb",
|
|
111
|
+
footerBg: "#000000",
|
|
112
|
+
treeLine: "#585b70",
|
|
113
|
+
previewKey: "#9399b2",
|
|
114
|
+
},
|
|
115
|
+
waterfall: {
|
|
116
|
+
bar: "#f5c2e7",
|
|
117
|
+
barError: "#f38ba8",
|
|
118
|
+
barBg: "#1e1e2e",
|
|
119
|
+
barLane: "#313244",
|
|
120
|
+
barSelected: "#fab387",
|
|
121
|
+
barSelectedError: "#eba0ac",
|
|
122
|
+
},
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const themes = {
|
|
126
|
+
"motel-default": motelDefaultTheme,
|
|
127
|
+
"tokyo-night": tokyoNightTheme,
|
|
128
|
+
catppuccin: catppuccinTheme,
|
|
129
|
+
} as const
|
|
130
|
+
|
|
131
|
+
export type ThemeName = keyof typeof themes
|
|
132
|
+
|
|
133
|
+
export const themeOrder: readonly ThemeName[] = ["motel-default", "tokyo-night", "catppuccin"]
|
|
134
|
+
|
|
135
|
+
export const colors: ThemeColors = { ...motelDefaultTheme.colors }
|
|
136
|
+
export const waterfallColors: ThemeWaterfallColors = { ...motelDefaultTheme.waterfall }
|
|
137
|
+
|
|
138
|
+
export const applyTheme = (name: ThemeName) => {
|
|
139
|
+
const theme = themes[name] ?? motelDefaultTheme
|
|
140
|
+
Object.assign(colors, theme.colors)
|
|
141
|
+
Object.assign(waterfallColors, theme.waterfall)
|
|
142
|
+
return theme
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const cycleThemeName = (current: ThemeName) => {
|
|
146
|
+
const nextIndex = (themeOrder.indexOf(current) + 1) % themeOrder.length
|
|
147
|
+
return themeOrder[nextIndex] ?? themeOrder[0]
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const themeLabel = (name: ThemeName) => themes[name]?.label ?? motelDefaultTheme.label
|
|
151
|
+
|
|
152
|
+
export const SEPARATOR = " \u00b7 "
|
|
153
|
+
export const G_PREFIX_TIMEOUT_MS = 500
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end reproducer for waterfall underfilling the trace-details pane.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* 1. Seed a deterministic trace into a fresh SQLite database.
|
|
6
|
+
* 2. Launch the motel TUI under tuistory in narrow mode so trace details take
|
|
7
|
+
* the full screen width.
|
|
8
|
+
* 3. Drill into the trace details view.
|
|
9
|
+
* 4. Assert the root waterfall row reaches the right-side duration column
|
|
10
|
+
* instead of stopping several cells early.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { afterAll, beforeAll, describe, expect, it } from "bun:test"
|
|
14
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs"
|
|
15
|
+
import { tmpdir } from "node:os"
|
|
16
|
+
import { join } from "node:path"
|
|
17
|
+
|
|
18
|
+
const TUISTORY_BIN = "tuistory"
|
|
19
|
+
const SESSION = `motel-trace-width-${Date.now()}`
|
|
20
|
+
|
|
21
|
+
const hasTuistory = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const proc = Bun.spawn({ cmd: ["which", TUISTORY_BIN], stdout: "pipe", stderr: "ignore" })
|
|
24
|
+
return (await proc.exited) === 0
|
|
25
|
+
} catch {
|
|
26
|
+
return false
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const tui = async (args: readonly string[]) => {
|
|
31
|
+
const proc = Bun.spawn({ cmd: [TUISTORY_BIN, ...args], stdout: "pipe", stderr: "pipe" })
|
|
32
|
+
const [stdout, stderr] = await Promise.all([
|
|
33
|
+
new Response(proc.stdout).text(),
|
|
34
|
+
new Response(proc.stderr).text(),
|
|
35
|
+
])
|
|
36
|
+
return { code: await proc.exited, stdout, stderr }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const snapshot = async () => (await tui(["snapshot", "--session", SESSION])).stdout
|
|
40
|
+
|
|
41
|
+
const press = async (...keys: string[]) => {
|
|
42
|
+
await tui(["press", "--session", SESSION, ...keys])
|
|
43
|
+
await Bun.sleep(120)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const dividerWidth = (snap: string) =>
|
|
47
|
+
snap.split("\n").find((line) => line.startsWith("─"))?.length ?? 0
|
|
48
|
+
|
|
49
|
+
const rootWaterfallRow = (snap: string) =>
|
|
50
|
+
snap.split("\n").find((line) => line.startsWith(" ▾ root.op") || line.startsWith(" ▸ root.op") || line.startsWith(" · root.op")) ?? null
|
|
51
|
+
|
|
52
|
+
describe("trace details waterfall width (end-to-end TUI)", () => {
|
|
53
|
+
const tempDir = mkdtempSync(join(tmpdir(), "motel-trace-width-"))
|
|
54
|
+
const dbPath = join(tempDir, "telemetry.sqlite")
|
|
55
|
+
const lastServicePath = join(tempDir, "last-service.txt")
|
|
56
|
+
let canRun = false
|
|
57
|
+
|
|
58
|
+
beforeAll(async () => {
|
|
59
|
+
canRun = await hasTuistory()
|
|
60
|
+
if (!canRun) return
|
|
61
|
+
|
|
62
|
+
writeFileSync(lastServicePath, "waterfall-repro")
|
|
63
|
+
|
|
64
|
+
const seed = Bun.spawn({
|
|
65
|
+
cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
|
|
66
|
+
cwd: process.cwd(),
|
|
67
|
+
env: {
|
|
68
|
+
...process.env,
|
|
69
|
+
MOTEL_OTEL_DB_PATH: dbPath,
|
|
70
|
+
MOTEL_OTEL_ENABLED: "false",
|
|
71
|
+
},
|
|
72
|
+
stdout: "pipe",
|
|
73
|
+
stderr: "pipe",
|
|
74
|
+
})
|
|
75
|
+
const seedCode = await seed.exited
|
|
76
|
+
if (seedCode !== 0) {
|
|
77
|
+
const err = await new Response(seed.stderr).text()
|
|
78
|
+
throw new Error(`seed failed: ${err}`)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await tui(["close", "--session", SESSION])
|
|
82
|
+
const launch = await tui([
|
|
83
|
+
"launch",
|
|
84
|
+
"bun run src/index.tsx",
|
|
85
|
+
"--session", SESSION,
|
|
86
|
+
"--cols", "96",
|
|
87
|
+
"--rows", "40",
|
|
88
|
+
"--cwd", process.cwd(),
|
|
89
|
+
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
90
|
+
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
91
|
+
"--timeout", "15000",
|
|
92
|
+
])
|
|
93
|
+
if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
|
|
94
|
+
await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
|
|
95
|
+
await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
|
|
96
|
+
}, 60_000)
|
|
97
|
+
|
|
98
|
+
afterAll(async () => {
|
|
99
|
+
if (canRun) await tui(["close", "--session", SESSION])
|
|
100
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it("fills the full-width trace details pane in narrow mode", async () => {
|
|
104
|
+
if (!canRun) return
|
|
105
|
+
|
|
106
|
+
await press("return")
|
|
107
|
+
const snap = await snapshot()
|
|
108
|
+
const divider = dividerWidth(snap)
|
|
109
|
+
const row = rootWaterfallRow(snap)
|
|
110
|
+
|
|
111
|
+
expect(divider).toBe(96)
|
|
112
|
+
expect(row).not.toBeNull()
|
|
113
|
+
expect(row!.length).toBeGreaterThanOrEqual(divider - 1)
|
|
114
|
+
}, 60_000)
|
|
115
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Seed script for traceSortNav.repro.test.ts. Invoked as a child process so
|
|
3
|
+
* `config.ts` picks up our test DB path at module-load time.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Effect } from "effect"
|
|
7
|
+
import { storeRuntime } from "../runtime.ts"
|
|
8
|
+
import { TelemetryStore } from "../services/TelemetryStore.ts"
|
|
9
|
+
|
|
10
|
+
const SERVICE_NAME = "sort-nav-repro"
|
|
11
|
+
const base = BigInt(Date.now()) * 1_000_000n
|
|
12
|
+
const ms = (n: number) => String(base + BigInt(n) * 1_000_000n)
|
|
13
|
+
|
|
14
|
+
// A is the oldest, E is the newest. Durations are arranged so the three
|
|
15
|
+
// sort orders we care about all differ:
|
|
16
|
+
// recent = [E, D, C, B, A]
|
|
17
|
+
// slowest = [D, B, E, A, C]
|
|
18
|
+
// errors = none set, falls back to recency
|
|
19
|
+
// Enough traces that the list scrolls in a typical 40-row terminal, and
|
|
20
|
+
// enough duration spread that `slowest` order is meaningfully different
|
|
21
|
+
// from `recent`.
|
|
22
|
+
const specs: ReadonlyArray<{ id: string; op: string; startMsAgo: number; durMs: number }> = [
|
|
23
|
+
{ id: "a", op: "opA", startMsAgo: 1500, durMs: 10 },
|
|
24
|
+
{ id: "b", op: "opB", startMsAgo: 1400, durMs: 50 },
|
|
25
|
+
{ id: "c", op: "opC", startMsAgo: 1300, durMs: 5 },
|
|
26
|
+
{ id: "d", op: "opD", startMsAgo: 1200, durMs: 100 },
|
|
27
|
+
{ id: "e", op: "opE", startMsAgo: 1100, durMs: 25 },
|
|
28
|
+
{ id: "f", op: "opF", startMsAgo: 1000, durMs: 75 },
|
|
29
|
+
{ id: "g", op: "opG", startMsAgo: 900, durMs: 3 },
|
|
30
|
+
{ id: "h", op: "opH", startMsAgo: 800, durMs: 200 },
|
|
31
|
+
{ id: "i", op: "opI", startMsAgo: 700, durMs: 15 },
|
|
32
|
+
{ id: "j", op: "opJ", startMsAgo: 600, durMs: 60 },
|
|
33
|
+
{ id: "k", op: "opK", startMsAgo: 500, durMs: 1 },
|
|
34
|
+
{ id: "l", op: "opL", startMsAgo: 400, durMs: 150 },
|
|
35
|
+
{ id: "m", op: "opM", startMsAgo: 300, durMs: 40 },
|
|
36
|
+
{ id: "n", op: "opN", startMsAgo: 200, durMs: 20 },
|
|
37
|
+
{ id: "o", op: "opO", startMsAgo: 100, durMs: 7 },
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
const resourceSpans = specs.map((spec) => ({
|
|
41
|
+
resource: { attributes: [{ key: "service.name", value: { stringValue: SERVICE_NAME } }] },
|
|
42
|
+
scopeSpans: [
|
|
43
|
+
{
|
|
44
|
+
scope: { name: "s" },
|
|
45
|
+
spans: [
|
|
46
|
+
{
|
|
47
|
+
traceId: spec.id.repeat(32),
|
|
48
|
+
spanId: spec.id.repeat(16),
|
|
49
|
+
name: spec.op,
|
|
50
|
+
kind: 1,
|
|
51
|
+
startTimeUnixNano: ms(-spec.startMsAgo),
|
|
52
|
+
endTimeUnixNano: ms(-spec.startMsAgo + spec.durMs),
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
}))
|
|
58
|
+
|
|
59
|
+
await storeRuntime.runPromise(
|
|
60
|
+
Effect.flatMap(TelemetryStore.asEffect(), (store) => store.ingestTraces({ resourceSpans })),
|
|
61
|
+
)
|
|
62
|
+
process.exit(0)
|