@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
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { Children } from "react"
|
|
3
|
+
import { colors } from "./theme.ts"
|
|
4
|
+
import { fitCell, truncateText } from "./format.ts"
|
|
5
|
+
import type { DetailView } from "./atoms.ts"
|
|
6
|
+
|
|
7
|
+
export const BlankRow = () => <box height={1} />
|
|
8
|
+
|
|
9
|
+
export const PlainLine = ({ text, fg = colors.text, bold = false }: { text: string; fg?: string; bold?: boolean }) => (
|
|
10
|
+
<box height={1}>
|
|
11
|
+
{bold ? (
|
|
12
|
+
<text wrapMode="none" truncate fg={fg} attributes={TextAttributes.BOLD}>
|
|
13
|
+
{text}
|
|
14
|
+
</text>
|
|
15
|
+
) : (
|
|
16
|
+
<text wrapMode="none" truncate fg={fg}>
|
|
17
|
+
{text}
|
|
18
|
+
</text>
|
|
19
|
+
)}
|
|
20
|
+
</box>
|
|
21
|
+
)
|
|
22
|
+
|
|
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
|
+
}
|
|
49
|
+
|
|
50
|
+
export const AlignedHeaderLine = ({ left, right, width, rightFg = colors.muted }: { left: string; right: string; width: number; rightFg?: string }) => {
|
|
51
|
+
const availableRightWidth = Math.max(8, width - left.length - 2)
|
|
52
|
+
const rightText = truncateText(right, availableRightWidth)
|
|
53
|
+
const gap = Math.max(2, width - left.length - rightText.length)
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<TextLine>
|
|
57
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{left}</span>
|
|
58
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
59
|
+
<span fg={rightFg}>{rightText}</span>
|
|
60
|
+
</TextLine>
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const Divider = ({ width }: { width: number }) => (
|
|
65
|
+
<PlainLine text={"\u2500".repeat(Math.max(1, width))} fg={colors.separator} />
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
/** Horizontal divider split into left ─ junction ─ right, using flex row so
|
|
69
|
+
* the junction character lands at exactly the same column as the SeparatorColumn. */
|
|
70
|
+
export const SplitDivider = ({ leftWidth, junction, rightWidth }: { leftWidth: number; junction: string; rightWidth: number }) => (
|
|
71
|
+
<box flexDirection="row" height={1}>
|
|
72
|
+
<box width={leftWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(leftWidth)}</text></box>
|
|
73
|
+
<box width={1}><text fg={colors.separator}>{junction}</text></box>
|
|
74
|
+
<box width={rightWidth}><text fg={colors.separator} wrapMode="none" truncate>{"\u2500".repeat(rightWidth)}</text></box>
|
|
75
|
+
</box>
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
/** Row index → junction character override. Callers supply exactly the
|
|
79
|
+
* glyph they want at each row so the separator lines up with whatever
|
|
80
|
+
* divider geometry the neighboring panes happen to have:
|
|
81
|
+
* - `\u251c` (├) when only the right pane has a divider at that row
|
|
82
|
+
* - `\u2524` (┤) when only the left pane has a divider at that row
|
|
83
|
+
* - `\u253c` (┼) when both do
|
|
84
|
+
*/
|
|
85
|
+
export const SeparatorColumn = ({ height, junctionChars }: { height: number; junctionChars?: ReadonlyMap<number, string> }) => {
|
|
86
|
+
const lines: string[] = []
|
|
87
|
+
for (let i = 0; i < Math.max(1, height); i++) {
|
|
88
|
+
lines.push(junctionChars?.get(i) ?? "\u2502")
|
|
89
|
+
}
|
|
90
|
+
return (
|
|
91
|
+
<box width={1} height={height} overflow="hidden">
|
|
92
|
+
<text fg={colors.separator}>{lines.join("\n")}</text>
|
|
93
|
+
</box>
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
|
|
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
|
+
}
|
|
116
|
+
|
|
117
|
+
const FooterKey = ({ label }: { label: string }) => <span fg={colors.count} attributes={TextAttributes.BOLD}>{label}</span>
|
|
118
|
+
|
|
119
|
+
export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: { width: number; height: number; autoRefresh: boolean; themeLabel: string; onClose: () => void }) => {
|
|
120
|
+
const panelWidth = Math.min(76, Math.max(52, width - 10))
|
|
121
|
+
const left = Math.max(2, Math.floor((width - panelWidth) / 2))
|
|
122
|
+
const top = Math.max(1, Math.floor(height / 5))
|
|
123
|
+
const sectionGap = Math.max(1, panelWidth - 24)
|
|
124
|
+
const row = (key: string, desc: string) => (
|
|
125
|
+
<TextLine>
|
|
126
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>{key.padEnd(11)}</span>
|
|
127
|
+
<span fg={colors.muted}>{desc}</span>
|
|
128
|
+
</TextLine>
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
|
|
133
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
|
|
134
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
|
|
135
|
+
<TextLine>
|
|
136
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>Help</span>
|
|
137
|
+
<span fg={colors.muted}>{" ".repeat(sectionGap)}</span>
|
|
138
|
+
<span fg={colors.muted}>esc / enter / ? close</span>
|
|
139
|
+
</TextLine>
|
|
140
|
+
<BlankRow />
|
|
141
|
+
{row("j k ↑ ↓", "move selection")}
|
|
142
|
+
{row("enter", "select spans / open detail / jump from logs")}
|
|
143
|
+
{row("esc", "back out of detail or span selection")}
|
|
144
|
+
{row("t", `cycle theme (${themeLabel})`)}
|
|
145
|
+
{row("tab", "toggle service logs")}
|
|
146
|
+
{row("[ ]", "switch service")}
|
|
147
|
+
{row("/", "filter by root operation (\u2003:error, :ai <query>)")}
|
|
148
|
+
{row("f", "filter traces by span attribute")}
|
|
149
|
+
{row("s", "cycle sort mode")}
|
|
150
|
+
{row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
|
|
151
|
+
{row("r", "refresh traces")}
|
|
152
|
+
{row("y", "copy selected trace/span ids")}
|
|
153
|
+
{row("o / O", "open trace / open web UI")}
|
|
154
|
+
{row("gg / G", "jump to first / last")}
|
|
155
|
+
{row("^d / ^u", "page down / up")}
|
|
156
|
+
{row("q", "quit")}
|
|
157
|
+
</box>
|
|
158
|
+
</box>
|
|
159
|
+
</box>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export const FooterHints = ({ spanNavActive, detailView, autoRefresh, width: _width }: { spanNavActive: boolean; detailView: DetailView; autoRefresh: boolean; width: number }) => {
|
|
164
|
+
const enterAction = detailView === "service-logs"
|
|
165
|
+
? "trace"
|
|
166
|
+
: spanNavActive && detailView === "waterfall"
|
|
167
|
+
? "detail"
|
|
168
|
+
: "spans"
|
|
169
|
+
const escAction = spanNavActive
|
|
170
|
+
? (detailView === "span-detail" ? "back" : "traces")
|
|
171
|
+
: null
|
|
172
|
+
const items: Array<[string, string]> = [
|
|
173
|
+
["j/k", "move"],
|
|
174
|
+
["enter", enterAction],
|
|
175
|
+
...(escAction ? [["esc", escAction] as [string, string]] : []),
|
|
176
|
+
["t", "theme"],
|
|
177
|
+
["tab", "logs"],
|
|
178
|
+
["/", "filter"],
|
|
179
|
+
["f", "attr"],
|
|
180
|
+
["s", "sort"],
|
|
181
|
+
["a", autoRefresh ? "live" : "paused"],
|
|
182
|
+
["?", "help"],
|
|
183
|
+
["q", "quit"],
|
|
184
|
+
]
|
|
185
|
+
const renderItems = (items: ReadonlyArray<readonly [string, string]>) => (
|
|
186
|
+
items.flatMap(([key, label], index) => [
|
|
187
|
+
<FooterKey key={`${key}-key`} label={key} />,
|
|
188
|
+
<span key={`${key}-label`} fg={colors.muted}>{` ${label}`}</span>,
|
|
189
|
+
index < items.length - 1 ? <span key={`${key}-sep`} fg={colors.separator}>{" · "}</span> : null,
|
|
190
|
+
])
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
return (
|
|
194
|
+
<TextLine>
|
|
195
|
+
{renderItems(items)}
|
|
196
|
+
</TextLine>
|
|
197
|
+
)
|
|
198
|
+
}
|
package/src/ui/state.ts
ADDED
package/src/ui/theme.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
// motel-default palette derived in OKLCH. All "surface" tokens share hue
|
|
36
|
+
// 282 (twilight purple) at varying lightness so depth is communicated by
|
|
37
|
+
// lightness alone (footer < screen < selected < bar track). The amber
|
|
38
|
+
// accent (hue 73) sits almost complementary to the surfaces, giving the
|
|
39
|
+
// motel-sign neon maximum contrast without color clash.
|
|
40
|
+
const motelDefaultTheme: ThemeDefinition = {
|
|
41
|
+
name: "motel-default",
|
|
42
|
+
label: "Motel Default",
|
|
43
|
+
colors: {
|
|
44
|
+
screenBg: "#111120", // oklch(0.185 0.030 282)
|
|
45
|
+
text: "#eee5d6", // oklch(0.925 0.022 82) — warm cream
|
|
46
|
+
muted: "#9a9181", // oklch(0.660 0.025 82)
|
|
47
|
+
separator: "#686155", // oklch(0.495 0.020 81)
|
|
48
|
+
accent: "#f5a41a", // oklch(0.780 0.161 73) — motel neon
|
|
49
|
+
error: "#f97312", // oklch(0.705 0.187 48)
|
|
50
|
+
selectedBg: "#2b2c48", // oklch(0.305 0.050 282) — same hue as screen
|
|
51
|
+
warning: "#facc16", // oklch(0.861 0.173 92)
|
|
52
|
+
selectedText: "#f8fafc",
|
|
53
|
+
count: "#d7c5a1", // oklch(0.830 0.052 85)
|
|
54
|
+
passing: "#7ed5a4", // oklch(0.805 0.110 158)
|
|
55
|
+
defaultService: "#93c5fe", // oklch(0.810 0.096 252)
|
|
56
|
+
footerBg: "#04040e", // oklch(0.115 0.025 282) — deeper than screen
|
|
57
|
+
treeLine: "#48433b", // oklch(0.385 0.015 80)
|
|
58
|
+
previewKey: "#645d51", // oklch(0.480 0.020 80)
|
|
59
|
+
},
|
|
60
|
+
waterfall: {
|
|
61
|
+
bar: "#f5a41a", // = accent
|
|
62
|
+
barError: "#f97312", // = error
|
|
63
|
+
barBg: "#1f1f34", // oklch(0.250 0.040 282) — purple track (was warm)
|
|
64
|
+
barLane: "#3d3e5b", // oklch(0.375 0.050 282)
|
|
65
|
+
barSelected: "#f3c048", // oklch(0.832 0.145 85) — warmer amber
|
|
66
|
+
barSelectedError: "#ff8c42",
|
|
67
|
+
},
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const tokyoNightTheme: ThemeDefinition = {
|
|
71
|
+
name: "tokyo-night",
|
|
72
|
+
label: "Tokyo Night",
|
|
73
|
+
colors: {
|
|
74
|
+
screenBg: "#1a1b26",
|
|
75
|
+
text: "#c0caf5",
|
|
76
|
+
muted: "#7a88b6",
|
|
77
|
+
separator: "#565f89",
|
|
78
|
+
accent: "#7aa2f7",
|
|
79
|
+
error: "#f7768e",
|
|
80
|
+
selectedBg: "#283457",
|
|
81
|
+
warning: "#e0af68",
|
|
82
|
+
selectedText: "#f8fbff",
|
|
83
|
+
count: "#bb9af7",
|
|
84
|
+
passing: "#9ece6a",
|
|
85
|
+
defaultService: "#73daca",
|
|
86
|
+
footerBg: "#000000",
|
|
87
|
+
treeLine: "#414868",
|
|
88
|
+
previewKey: "#6b739c",
|
|
89
|
+
},
|
|
90
|
+
waterfall: {
|
|
91
|
+
bar: "#7aa2f7",
|
|
92
|
+
barError: "#f7768e",
|
|
93
|
+
barBg: "#1f2335",
|
|
94
|
+
barLane: "#2a3050",
|
|
95
|
+
barSelected: "#bb9af7",
|
|
96
|
+
barSelectedError: "#ff9eaf",
|
|
97
|
+
},
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const catppuccinTheme: ThemeDefinition = {
|
|
101
|
+
name: "catppuccin",
|
|
102
|
+
label: "Catppuccin Mocha",
|
|
103
|
+
colors: {
|
|
104
|
+
screenBg: "#11111b",
|
|
105
|
+
text: "#cdd6f4",
|
|
106
|
+
muted: "#a6adc8",
|
|
107
|
+
separator: "#6c7086",
|
|
108
|
+
accent: "#f5c2e7",
|
|
109
|
+
error: "#f38ba8",
|
|
110
|
+
selectedBg: "#313244",
|
|
111
|
+
warning: "#f9e2af",
|
|
112
|
+
selectedText: "#f5f7ff",
|
|
113
|
+
count: "#fab387",
|
|
114
|
+
passing: "#a6e3a1",
|
|
115
|
+
defaultService: "#89dceb",
|
|
116
|
+
footerBg: "#000000",
|
|
117
|
+
treeLine: "#585b70",
|
|
118
|
+
previewKey: "#9399b2",
|
|
119
|
+
},
|
|
120
|
+
waterfall: {
|
|
121
|
+
bar: "#f5c2e7",
|
|
122
|
+
barError: "#f38ba8",
|
|
123
|
+
barBg: "#1e1e2e",
|
|
124
|
+
barLane: "#313244",
|
|
125
|
+
barSelected: "#fab387",
|
|
126
|
+
barSelectedError: "#eba0ac",
|
|
127
|
+
},
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const themes = {
|
|
131
|
+
"motel-default": motelDefaultTheme,
|
|
132
|
+
"tokyo-night": tokyoNightTheme,
|
|
133
|
+
catppuccin: catppuccinTheme,
|
|
134
|
+
} as const
|
|
135
|
+
|
|
136
|
+
export type ThemeName = keyof typeof themes
|
|
137
|
+
|
|
138
|
+
export const defaultThemeName: ThemeName = "tokyo-night"
|
|
139
|
+
|
|
140
|
+
export const themeOrder: readonly ThemeName[] = ["tokyo-night", "catppuccin", "motel-default"]
|
|
141
|
+
|
|
142
|
+
export const colors: ThemeColors = { ...themes[defaultThemeName].colors }
|
|
143
|
+
export const waterfallColors: ThemeWaterfallColors = { ...themes[defaultThemeName].waterfall }
|
|
144
|
+
|
|
145
|
+
export const applyTheme = (name: ThemeName) => {
|
|
146
|
+
const theme = themes[name] ?? themes[defaultThemeName]
|
|
147
|
+
Object.assign(colors, theme.colors)
|
|
148
|
+
Object.assign(waterfallColors, theme.waterfall)
|
|
149
|
+
return theme
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export const cycleThemeName = (current: ThemeName) => {
|
|
153
|
+
const nextIndex = (themeOrder.indexOf(current) + 1) % themeOrder.length
|
|
154
|
+
return themeOrder[nextIndex] ?? themeOrder[0]
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export const themeLabel = (name: ThemeName) => themes[name]?.label ?? themes[defaultThemeName].label
|
|
158
|
+
|
|
159
|
+
export const SEPARATOR = " \u00b7 "
|
|
160
|
+
export const G_PREFIX_TIMEOUT_MS = 500
|
|
@@ -0,0 +1,126 @@
|
|
|
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 port = 45_000 + Math.floor(Math.random() * 5_000)
|
|
56
|
+
const daemonEnv = { MOTEL_RUNTIME_DIR: tempDir, MOTEL_OTEL_DB_PATH: dbPath, MOTEL_OTEL_BASE_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_QUERY_URL: `http://127.0.0.1:${port}`, MOTEL_OTEL_PORT: String(port) }
|
|
57
|
+
const lastServicePath = join(tempDir, "last-service.txt")
|
|
58
|
+
let canRun = false
|
|
59
|
+
|
|
60
|
+
beforeAll(async () => {
|
|
61
|
+
canRun = await hasTuistory()
|
|
62
|
+
if (!canRun) return
|
|
63
|
+
|
|
64
|
+
writeFileSync(lastServicePath, "waterfall-repro")
|
|
65
|
+
|
|
66
|
+
const seed = Bun.spawn({
|
|
67
|
+
cmd: ["bun", "run", "src/ui/waterfallNav.repro.seed.ts"],
|
|
68
|
+
cwd: process.cwd(),
|
|
69
|
+
env: {
|
|
70
|
+
...process.env,
|
|
71
|
+
...daemonEnv,
|
|
72
|
+
MOTEL_OTEL_DB_PATH: dbPath,
|
|
73
|
+
MOTEL_OTEL_ENABLED: "false",
|
|
74
|
+
},
|
|
75
|
+
stdout: "pipe",
|
|
76
|
+
stderr: "pipe",
|
|
77
|
+
})
|
|
78
|
+
const seedCode = await seed.exited
|
|
79
|
+
if (seedCode !== 0) {
|
|
80
|
+
const err = await new Response(seed.stderr).text()
|
|
81
|
+
throw new Error(`seed failed: ${err}`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await tui(["close", "--session", SESSION])
|
|
85
|
+
const launch = await tui([
|
|
86
|
+
"launch",
|
|
87
|
+
"bun run src/index.tsx",
|
|
88
|
+
"--session", SESSION,
|
|
89
|
+
"--cols", "96",
|
|
90
|
+
"--rows", "40",
|
|
91
|
+
"--cwd", process.cwd(),
|
|
92
|
+
"--env", `MOTEL_OTEL_DB_PATH=${dbPath}`,
|
|
93
|
+
"--env", `MOTEL_RUNTIME_DIR=${tempDir}`,
|
|
94
|
+
"--env", `MOTEL_OTEL_BASE_URL=${daemonEnv.MOTEL_OTEL_BASE_URL}`,
|
|
95
|
+
"--env", `MOTEL_OTEL_QUERY_URL=${daemonEnv.MOTEL_OTEL_QUERY_URL}`,
|
|
96
|
+
"--env", `MOTEL_OTEL_PORT=${port}`,
|
|
97
|
+
"--env", "MOTEL_OTEL_ENABLED=false",
|
|
98
|
+
"--timeout", "15000",
|
|
99
|
+
])
|
|
100
|
+
if (launch.code !== 0) throw new Error(`launch failed: ${launch.stderr}`)
|
|
101
|
+
await tui(["wait", "root.op", "--session", SESSION, "--timeout", "10000"])
|
|
102
|
+
await tui(["wait-idle", "--session", SESSION, "--timeout", "5000"])
|
|
103
|
+
}, 60_000)
|
|
104
|
+
|
|
105
|
+
afterAll(async () => {
|
|
106
|
+
if (canRun) {
|
|
107
|
+
await tui(["close", "--session", SESSION])
|
|
108
|
+
const stop = Bun.spawn({ cmd: ["bun", "run", "src/motel.ts", "stop"], cwd: process.cwd(), env: { ...process.env, ...daemonEnv }, stdout: "ignore", stderr: "ignore" })
|
|
109
|
+
await stop.exited
|
|
110
|
+
}
|
|
111
|
+
try { rmSync(tempDir, { recursive: true, force: true }) } catch {}
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it("fills the full-width trace details pane in narrow mode", async () => {
|
|
115
|
+
if (!canRun) return
|
|
116
|
+
|
|
117
|
+
await press("return")
|
|
118
|
+
const snap = await snapshot()
|
|
119
|
+
const divider = dividerWidth(snap)
|
|
120
|
+
const row = rootWaterfallRow(snap)
|
|
121
|
+
|
|
122
|
+
expect(divider).toBe(96)
|
|
123
|
+
expect(row).not.toBeNull()
|
|
124
|
+
expect(row!.length).toBeGreaterThanOrEqual(divider - 1)
|
|
125
|
+
}, 60_000)
|
|
126
|
+
})
|
|
@@ -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, (store) => store.ingestTraces({ resourceSpans })),
|
|
61
|
+
)
|
|
62
|
+
process.exit(0)
|