@kitlangton/motel 0.1.1 → 0.1.2
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 +22 -8
- package/package.json +2 -1
- package/src/App.tsx +38 -28
- package/src/config.ts +1 -1
- package/src/httpApi.ts +5 -2
- package/src/localServer.ts +1 -0
- package/src/motel.ts +12 -0
- package/src/services/TelemetryStore.ts +99 -23
- package/src/services/TraceQueryService.ts +4 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/SpanDetailPane.tsx +1 -2
- package/src/ui/TraceDetailsPane.tsx +14 -22
- package/src/ui/TraceList.tsx +166 -40
- package/src/ui/Waterfall.tsx +104 -46
- package/src/ui/app/TraceListPane.tsx +19 -14
- package/src/ui/app/TraceWorkspace.tsx +60 -31
- package/src/ui/app/useAppLayout.ts +22 -3
- package/src/ui/app/useTraceScreenData.ts +13 -2
- package/src/ui/format.ts +14 -5
- package/src/ui/primitives.tsx +3 -1
- package/src/ui/state.ts +32 -0
- package/src/ui/theme.ts +24 -19
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/useAttrFilterPicker.ts +47 -0
- package/src/ui/useKeyboardNav.ts +114 -15
- package/src/ui/waterfallNav.test.ts +22 -7
- package/web/dist/assets/{index-BEKIiisE.js → index-DKinj-OE.js} +1 -1
- package/web/dist/index.html +1 -1
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
-
import type { RefObject } from "react"
|
|
3
1
|
import type { LogItem, TraceItem, TraceSummaryItem } from "../../domain.ts"
|
|
4
2
|
import { formatShortDate, formatTimestamp } from "../format.ts"
|
|
5
3
|
import { AlignedHeaderLine, BlankRow, Divider, SeparatorColumn, TextLine } from "../primitives.tsx"
|
|
@@ -21,7 +19,6 @@ interface TraceWorkspaceProps {
|
|
|
21
19
|
readonly filterMode: boolean
|
|
22
20
|
readonly filterText: string
|
|
23
21
|
readonly traceListProps: TraceListProps
|
|
24
|
-
readonly traceListScrollRef: RefObject<ScrollBoxRenderable | null>
|
|
25
22
|
readonly selectedTraceService: string | null
|
|
26
23
|
readonly serviceLogState: ServiceLogState
|
|
27
24
|
readonly selectedServiceLogIndex: number
|
|
@@ -44,7 +41,6 @@ export const TraceWorkspace = ({
|
|
|
44
41
|
filterMode,
|
|
45
42
|
filterText,
|
|
46
43
|
traceListProps,
|
|
47
|
-
traceListScrollRef,
|
|
48
44
|
selectedTraceService,
|
|
49
45
|
serviceLogState,
|
|
50
46
|
selectedServiceLogIndex,
|
|
@@ -107,10 +103,11 @@ export const TraceWorkspace = ({
|
|
|
107
103
|
}
|
|
108
104
|
|
|
109
105
|
if (isWideLayout) {
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
106
|
+
// L0: list (left) + trace preview (right). The two-pane zoom.
|
|
107
|
+
if (viewLevel === 0) {
|
|
108
|
+
return (
|
|
109
|
+
<box flexGrow={1} flexDirection="row">
|
|
110
|
+
<box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
114
111
|
<TraceListPane
|
|
115
112
|
traceListProps={traceListProps}
|
|
116
113
|
filterMode={filterMode}
|
|
@@ -119,53 +116,86 @@ export const TraceWorkspace = ({
|
|
|
119
116
|
containerHeight={wideBodyHeight}
|
|
120
117
|
bodyHeight={wideTraceListBodyHeight}
|
|
121
118
|
padding={sectionPadding}
|
|
122
|
-
scrollRef={traceListScrollRef}
|
|
123
119
|
/>
|
|
124
|
-
|
|
120
|
+
</box>
|
|
121
|
+
<SeparatorColumn height={wideBodyHeight} junctionChars={separatorJunctionChars} />
|
|
122
|
+
<box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
125
123
|
<TraceDetailsPane
|
|
126
124
|
trace={selectedTrace}
|
|
127
125
|
traceSummary={selectedTraceSummary}
|
|
128
126
|
traceStatus={traceDetailState.status}
|
|
129
127
|
traceError={traceDetailState.error}
|
|
130
128
|
traceLogsState={logState}
|
|
131
|
-
contentWidth={
|
|
129
|
+
contentWidth={rightContentWidth}
|
|
132
130
|
bodyLines={wideBodyLines}
|
|
133
|
-
paneWidth={
|
|
131
|
+
paneWidth={rightPaneWidth}
|
|
134
132
|
selectedSpanIndex={selectedSpanIndex}
|
|
135
133
|
collapsedSpanIds={collapsedSpanIds}
|
|
136
134
|
focused={false}
|
|
137
135
|
onSelectSpan={selectSpan}
|
|
138
136
|
/>
|
|
139
|
-
|
|
137
|
+
</box>
|
|
140
138
|
</box>
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// L1: the user pressed enter on a trace — hide the list entirely and
|
|
143
|
+
// let the waterfall take the full width. `leftPaneWidth` is already
|
|
144
|
+
// `contentWidth` in this case (see useAppLayout), so one pane fills
|
|
145
|
+
// the row.
|
|
146
|
+
if (viewLevel === 1) {
|
|
147
|
+
return (
|
|
148
|
+
<box flexGrow={1} flexDirection="row">
|
|
149
|
+
<box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
144
150
|
<TraceDetailsPane
|
|
145
151
|
trace={selectedTrace}
|
|
146
152
|
traceSummary={selectedTraceSummary}
|
|
147
153
|
traceStatus={traceDetailState.status}
|
|
148
154
|
traceError={traceDetailState.error}
|
|
149
155
|
traceLogsState={logState}
|
|
150
|
-
contentWidth={
|
|
156
|
+
contentWidth={Math.max(24, leftPaneWidth - sectionPadding * 2)}
|
|
151
157
|
bodyLines={wideBodyLines}
|
|
152
|
-
paneWidth={
|
|
158
|
+
paneWidth={leftPaneWidth}
|
|
153
159
|
selectedSpanIndex={selectedSpanIndex}
|
|
154
160
|
collapsedSpanIds={collapsedSpanIds}
|
|
155
|
-
focused={viewLevel === 1}
|
|
156
|
-
onSelectSpan={selectSpan}
|
|
157
|
-
/>
|
|
158
|
-
) : (
|
|
159
|
-
<SpanDetailPane
|
|
160
|
-
span={selectedSpan}
|
|
161
|
-
trace={selectedTrace}
|
|
162
|
-
logs={selectedSpanLogs}
|
|
163
|
-
contentWidth={rightContentWidth}
|
|
164
|
-
bodyLines={wideBodyLines}
|
|
165
|
-
paneWidth={rightPaneWidth}
|
|
166
161
|
focused={true}
|
|
162
|
+
onSelectSpan={selectSpan}
|
|
167
163
|
/>
|
|
168
|
-
|
|
164
|
+
</box>
|
|
165
|
+
</box>
|
|
166
|
+
)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// L2: waterfall-left + span detail-right. Still no list.
|
|
170
|
+
return (
|
|
171
|
+
<box flexGrow={1} flexDirection="row">
|
|
172
|
+
<box width={leftPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
173
|
+
<TraceDetailsPane
|
|
174
|
+
trace={selectedTrace}
|
|
175
|
+
traceSummary={selectedTraceSummary}
|
|
176
|
+
traceStatus={traceDetailState.status}
|
|
177
|
+
traceError={traceDetailState.error}
|
|
178
|
+
traceLogsState={logState}
|
|
179
|
+
contentWidth={leftContentWidth}
|
|
180
|
+
bodyLines={wideBodyLines}
|
|
181
|
+
paneWidth={leftPaneWidth}
|
|
182
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
183
|
+
collapsedSpanIds={collapsedSpanIds}
|
|
184
|
+
focused={false}
|
|
185
|
+
onSelectSpan={selectSpan}
|
|
186
|
+
/>
|
|
187
|
+
</box>
|
|
188
|
+
<SeparatorColumn height={wideBodyHeight} junctionChars={separatorCrossChars} />
|
|
189
|
+
<box width={rightPaneWidth} height={wideBodyHeight} flexDirection="column">
|
|
190
|
+
<SpanDetailPane
|
|
191
|
+
span={selectedSpan}
|
|
192
|
+
trace={selectedTrace}
|
|
193
|
+
logs={selectedSpanLogs}
|
|
194
|
+
contentWidth={rightContentWidth}
|
|
195
|
+
bodyLines={wideBodyLines}
|
|
196
|
+
paneWidth={rightPaneWidth}
|
|
197
|
+
focused={true}
|
|
198
|
+
/>
|
|
169
199
|
</box>
|
|
170
200
|
</box>
|
|
171
201
|
)
|
|
@@ -182,7 +212,6 @@ export const TraceWorkspace = ({
|
|
|
182
212
|
containerHeight={narrowListHeight}
|
|
183
213
|
bodyHeight={narrowTraceListBodyHeight}
|
|
184
214
|
padding={sectionPadding}
|
|
185
|
-
scrollRef={traceListScrollRef}
|
|
186
215
|
/>
|
|
187
216
|
<Divider width={contentWidth} />
|
|
188
217
|
<TraceDetailsPane
|
|
@@ -25,10 +25,28 @@ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIn
|
|
|
25
25
|
detailView === "span-detail" ? 2 :
|
|
26
26
|
selectedSpanIndex !== null ? 1 :
|
|
27
27
|
0
|
|
28
|
+
// At L0 we show list + preview side-by-side. Once drilled in (L1/L2) the
|
|
29
|
+
// trace list is hidden entirely and the detail pane(s) take the full
|
|
30
|
+
// width — either one pane (waterfall at L1) or a 50/50 split between
|
|
31
|
+
// waterfall and span detail at L2.
|
|
28
32
|
const splitRatio = viewLevelForLayout === 2 ? 0.5 : 0.4
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
33
|
+
const listHidden = viewLevelForLayout >= 1
|
|
34
|
+
const leftPaneWidth = !isWideLayout
|
|
35
|
+
? contentWidth
|
|
36
|
+
: listHidden
|
|
37
|
+
? (viewLevelForLayout === 2 ? Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio)) : contentWidth)
|
|
38
|
+
: Math.max(40, Math.floor((contentWidth - splitGap) * splitRatio))
|
|
39
|
+
const rightPaneWidth = !isWideLayout
|
|
40
|
+
? contentWidth
|
|
41
|
+
: listHidden && viewLevelForLayout !== 2
|
|
42
|
+
? 0
|
|
43
|
+
: Math.max(28, contentWidth - leftPaneWidth - splitGap)
|
|
44
|
+
// Left pane: paddingLeft (1) + scrollbar column (1). No right padding —
|
|
45
|
+
// the vertical pane divider handles visual separation from the right pane.
|
|
46
|
+
const leftContentWidth = isWideLayout ? Math.max(24, leftPaneWidth - 2) : Math.max(24, contentWidth - sectionPadding * 2)
|
|
47
|
+
// Right pane: both left and right padding. Trace details and span detail
|
|
48
|
+
// content needs a little breathing room on the right so long op names
|
|
49
|
+
// and the duration column don't butt against the pane border.
|
|
32
50
|
const rightContentWidth = isWideLayout ? Math.max(24, rightPaneWidth - sectionPadding * 2) : Math.max(24, contentWidth - sectionPadding * 2)
|
|
33
51
|
const headerFooterWidth = Math.max(24, contentWidth - 2)
|
|
34
52
|
const wideBodyHeight = availableContentHeight
|
|
@@ -56,6 +74,7 @@ export const useAppLayout = ({ width, height, notice, detailView, selectedSpanIn
|
|
|
56
74
|
sectionPadding,
|
|
57
75
|
availableContentHeight,
|
|
58
76
|
viewLevel: viewLevelForLayout,
|
|
77
|
+
listHidden,
|
|
59
78
|
footerNotice,
|
|
60
79
|
footerHeight,
|
|
61
80
|
leftPaneWidth,
|
|
@@ -3,6 +3,8 @@ import { useCallback, useEffect, useMemo, useRef } from "react"
|
|
|
3
3
|
import { config } from "../../config.js"
|
|
4
4
|
import type { LogItem, TraceItem } from "../../domain.ts"
|
|
5
5
|
import {
|
|
6
|
+
activeAttrKeyAtom,
|
|
7
|
+
activeAttrValueAtom,
|
|
6
8
|
autoRefreshAtom,
|
|
7
9
|
collapsedSpanIdsAtom,
|
|
8
10
|
detailViewAtom,
|
|
@@ -11,6 +13,7 @@ import {
|
|
|
11
13
|
initialLogState,
|
|
12
14
|
initialServiceLogState,
|
|
13
15
|
initialTraceDetailState,
|
|
16
|
+
loadFilteredTraceSummaries,
|
|
14
17
|
loadRecentTraceSummaries,
|
|
15
18
|
loadServiceLogs,
|
|
16
19
|
loadTraceDetail,
|
|
@@ -47,6 +50,8 @@ export const useTraceScreenData = () => {
|
|
|
47
50
|
const [autoRefresh] = useAtom(autoRefreshAtom)
|
|
48
51
|
const [filterMode] = useAtom(filterModeAtom)
|
|
49
52
|
const [filterText] = useAtom(filterTextAtom)
|
|
53
|
+
const [activeAttrKey] = useAtom(activeAttrKeyAtom)
|
|
54
|
+
const [activeAttrValue] = useAtom(activeAttrValueAtom)
|
|
50
55
|
const [traceSort] = useAtom(traceSortAtom)
|
|
51
56
|
|
|
52
57
|
const selectedTraceRef = useRef<string | null>(null)
|
|
@@ -94,7 +99,11 @@ export const useTraceScreenData = () => {
|
|
|
94
99
|
setSelectedTraceService(effectiveService)
|
|
95
100
|
}
|
|
96
101
|
|
|
97
|
-
const traces = effectiveService
|
|
102
|
+
const traces = effectiveService
|
|
103
|
+
? (activeAttrKey && activeAttrValue
|
|
104
|
+
? await loadFilteredTraceSummaries(effectiveService, { [activeAttrKey]: activeAttrValue })
|
|
105
|
+
: await loadRecentTraceSummaries(effectiveService))
|
|
106
|
+
: []
|
|
98
107
|
if (cancelled) return
|
|
99
108
|
|
|
100
109
|
const prevTraceId = selectedTraceRef.current
|
|
@@ -117,7 +126,7 @@ export const useTraceScreenData = () => {
|
|
|
117
126
|
return () => {
|
|
118
127
|
cancelled = true
|
|
119
128
|
}
|
|
120
|
-
}, [refreshNonce, selectedTraceService, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
|
|
129
|
+
}, [refreshNonce, selectedTraceService, activeAttrKey, activeAttrValue, setSelectedTraceIndex, setSelectedTraceService, setTraceState])
|
|
121
130
|
|
|
122
131
|
useEffect(() => {
|
|
123
132
|
setSelectedTraceIndex((current) => {
|
|
@@ -402,6 +411,8 @@ export const useTraceScreenData = () => {
|
|
|
402
411
|
autoRefresh,
|
|
403
412
|
filterMode,
|
|
404
413
|
filterText,
|
|
414
|
+
activeAttrKey,
|
|
415
|
+
activeAttrValue,
|
|
405
416
|
traceSort,
|
|
406
417
|
selectedTraceSummary,
|
|
407
418
|
selectedTrace,
|
package/src/ui/format.ts
CHANGED
|
@@ -19,13 +19,22 @@ export const formatShortDate = (date: Date) => date.toLocaleDateString("en-US",
|
|
|
19
19
|
export const formatTimestamp = (date: Date) => date.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit" }).toLowerCase()
|
|
20
20
|
|
|
21
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" } => {
|
|
22
31
|
const trimDecimal = (value: string) => value.replace(/\.0+$/, "").replace(/(\.\d*[1-9])0+$/, "$1")
|
|
23
32
|
|
|
24
|
-
if (durationMs >= 10_000) return `${Math.round(durationMs / 1000)}s
|
|
25
|
-
if (durationMs >= 1000) return
|
|
26
|
-
if (durationMs >= 100) return `${Math.round(durationMs)}ms
|
|
27
|
-
if (durationMs >= 10) return
|
|
28
|
-
return
|
|
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" }
|
|
29
38
|
}
|
|
30
39
|
|
|
31
40
|
export const lifecycleLabel = (value: { readonly isRunning: boolean }) => (value.isRunning ? "open" : "closed")
|
package/src/ui/primitives.tsx
CHANGED
|
@@ -118,7 +118,8 @@ export const HelpModal = ({ width, height, autoRefresh, themeLabel, onClose }: {
|
|
|
118
118
|
{row("t", `cycle theme (${themeLabel})`)}
|
|
119
119
|
{row("tab", "toggle service logs")}
|
|
120
120
|
{row("[ ]", "switch service")}
|
|
121
|
-
{row("/", "filter
|
|
121
|
+
{row("/", "filter by root operation")}
|
|
122
|
+
{row("f", "filter traces by span attribute")}
|
|
122
123
|
{row("s", "cycle sort mode")}
|
|
123
124
|
{row("a", `auto refresh ${autoRefresh ? "on" : "off"}`)}
|
|
124
125
|
{row("r", "refresh traces")}
|
|
@@ -149,6 +150,7 @@ export const FooterHints = ({ spanNavActive, detailView, autoRefresh, width: _wi
|
|
|
149
150
|
["t", "theme"],
|
|
150
151
|
["tab", "logs"],
|
|
151
152
|
["/", "filter"],
|
|
153
|
+
["f", "attr"],
|
|
152
154
|
["s", "sort"],
|
|
153
155
|
["a", autoRefresh ? "live" : "paused"],
|
|
154
156
|
["?", "help"],
|
package/src/ui/state.ts
CHANGED
|
@@ -106,6 +106,32 @@ export const autoRefreshAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
|
106
106
|
export const filterModeAtom = Atom.make(false).pipe(Atom.keepAlive)
|
|
107
107
|
export const filterTextAtom = Atom.make("").pipe(Atom.keepAlive)
|
|
108
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
|
+
|
|
109
135
|
const lastThemePath = `${dirname(config.otel.databasePath)}/last-theme.txt`
|
|
110
136
|
const readLastTheme = (): ThemeName => {
|
|
111
137
|
try {
|
|
@@ -132,6 +158,12 @@ export const collapsedSpanIdsAtom = Atom.make(new Set<string>() as ReadonlySet<s
|
|
|
132
158
|
|
|
133
159
|
export const loadTraceServices = () => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.listServices))
|
|
134
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 })))
|
|
135
167
|
export const loadTraceDetail = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(TraceQueryService.asEffect(), (service) => service.getTrace(traceId)))
|
|
136
168
|
export const loadTraceLogs = (traceId: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listTraceLogs(traceId)))
|
|
137
169
|
export const loadServiceLogs = (serviceName: string) => queryRuntime.runPromise(Effect.flatMap(LogQueryService.asEffect(), (service) => service.listRecentLogs(serviceName)))
|
package/src/ui/theme.ts
CHANGED
|
@@ -32,32 +32,37 @@ export interface ThemeDefinition {
|
|
|
32
32
|
readonly waterfall: ThemeWaterfallColors
|
|
33
33
|
}
|
|
34
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.
|
|
35
40
|
const motelDefaultTheme: ThemeDefinition = {
|
|
36
41
|
name: "motel-default",
|
|
37
42
|
label: "Motel Default",
|
|
38
43
|
colors: {
|
|
39
|
-
screenBg: "#
|
|
40
|
-
text: "#
|
|
41
|
-
muted: "#
|
|
42
|
-
separator: "#
|
|
43
|
-
accent: "#
|
|
44
|
-
error: "#
|
|
45
|
-
selectedBg: "#
|
|
46
|
-
warning: "#
|
|
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)
|
|
47
52
|
selectedText: "#f8fafc",
|
|
48
|
-
count: "#d7c5a1",
|
|
49
|
-
passing: "#
|
|
50
|
-
defaultService: "#
|
|
51
|
-
footerBg: "#
|
|
52
|
-
treeLine: "#
|
|
53
|
-
previewKey: "#
|
|
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)
|
|
54
59
|
},
|
|
55
60
|
waterfall: {
|
|
56
|
-
bar: "#
|
|
57
|
-
barError: "#
|
|
58
|
-
barBg: "#
|
|
59
|
-
barLane: "#
|
|
60
|
-
barSelected: "#
|
|
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
|
|
61
66
|
barSelectedError: "#ff8c42",
|
|
62
67
|
},
|
|
63
68
|
}
|
|
@@ -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,47 @@
|
|
|
1
|
+
import { useAtom } from "@effect/atom-react"
|
|
2
|
+
import { useEffect } from "react"
|
|
3
|
+
import {
|
|
4
|
+
attrFacetStateAtom,
|
|
5
|
+
attrPickerModeAtom,
|
|
6
|
+
initialAttrFacetState,
|
|
7
|
+
loadTraceAttributeKeys,
|
|
8
|
+
loadTraceAttributeValues,
|
|
9
|
+
selectedTraceServiceAtom,
|
|
10
|
+
} from "./state.ts"
|
|
11
|
+
|
|
12
|
+
// When the picker is open, load the current facet page (keys, or values for
|
|
13
|
+
// a specific key) and keep it in sync with the selected service. We key the
|
|
14
|
+
// effect off picker mode + service + target key so refetches happen on drill
|
|
15
|
+
// in/out and when the user switches services mid-pick.
|
|
16
|
+
export const useAttrFilterPicker = (selectedKey: string | null) => {
|
|
17
|
+
const [pickerMode] = useAtom(attrPickerModeAtom)
|
|
18
|
+
const [service] = useAtom(selectedTraceServiceAtom)
|
|
19
|
+
const [, setFacetState] = useAtom(attrFacetStateAtom)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (pickerMode === "off" || !service) {
|
|
23
|
+
setFacetState(initialAttrFacetState)
|
|
24
|
+
return
|
|
25
|
+
}
|
|
26
|
+
let cancelled = false
|
|
27
|
+
setFacetState({ status: "loading", key: pickerMode === "values" ? selectedKey : null, data: [], error: null })
|
|
28
|
+
const load = async () => {
|
|
29
|
+
try {
|
|
30
|
+
const rows = pickerMode === "keys"
|
|
31
|
+
? await loadTraceAttributeKeys(service)
|
|
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
|
+
}
|
|
41
|
+
}
|
|
42
|
+
void load()
|
|
43
|
+
return () => {
|
|
44
|
+
cancelled = true
|
|
45
|
+
}
|
|
46
|
+
}, [pickerMode, service, selectedKey, setFacetState])
|
|
47
|
+
}
|