@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,169 @@
|
|
|
1
|
+
import { useMemo } from "react"
|
|
2
|
+
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
3
|
+
import { formatDuration, formatShortDate, formatTimestamp } from "./format.ts"
|
|
4
|
+
import { AlignedHeaderLine, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
5
|
+
import { getVisibleSpans, WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
|
+
import type { LoadStatus, LogState } from "./state.ts"
|
|
7
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Level-1 view: trace header + waterfall timeline body.
|
|
11
|
+
*
|
|
12
|
+
* Does not try to render a span detail/preview — the App orchestrates
|
|
13
|
+
* Level-2 layout separately (either as a second horizontal pane in wide
|
|
14
|
+
* mode, or as a full-screen takeover in narrow mode).
|
|
15
|
+
*
|
|
16
|
+
* Total height: `bodyLines + HEADER_ROWS`.
|
|
17
|
+
*/
|
|
18
|
+
export const TRACE_DETAILS_HEADER_ROWS = 4
|
|
19
|
+
|
|
20
|
+
export const TraceDetailsPane = ({
|
|
21
|
+
trace,
|
|
22
|
+
traceSummary,
|
|
23
|
+
traceStatus,
|
|
24
|
+
traceError,
|
|
25
|
+
traceLogsState,
|
|
26
|
+
contentWidth,
|
|
27
|
+
bodyLines,
|
|
28
|
+
paneWidth,
|
|
29
|
+
selectedSpanIndex,
|
|
30
|
+
collapsedSpanIds,
|
|
31
|
+
focused = false,
|
|
32
|
+
onSelectSpan,
|
|
33
|
+
}: {
|
|
34
|
+
trace: TraceItem | null
|
|
35
|
+
traceSummary: TraceSummaryItem | null
|
|
36
|
+
traceStatus: LoadStatus
|
|
37
|
+
traceError: string | null
|
|
38
|
+
traceLogsState: LogState
|
|
39
|
+
contentWidth: number
|
|
40
|
+
bodyLines: number
|
|
41
|
+
paneWidth: number
|
|
42
|
+
selectedSpanIndex: number | null
|
|
43
|
+
collapsedSpanIds: ReadonlySet<string>
|
|
44
|
+
focused?: boolean
|
|
45
|
+
onSelectSpan: (index: number) => void
|
|
46
|
+
}) => {
|
|
47
|
+
const filteredSpans = useMemo(
|
|
48
|
+
() => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
|
|
49
|
+
[trace, collapsedSpanIds],
|
|
50
|
+
)
|
|
51
|
+
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
52
|
+
const traceLogCount = traceLogsState.data.length
|
|
53
|
+
const spanLogCounts = useMemo(() => {
|
|
54
|
+
const counts = new Map<string, number>()
|
|
55
|
+
for (const log of traceLogsState.data) {
|
|
56
|
+
if (!log.spanId) continue
|
|
57
|
+
counts.set(log.spanId, (counts.get(log.spanId) ?? 0) + 1)
|
|
58
|
+
}
|
|
59
|
+
return counts
|
|
60
|
+
}, [traceLogsState.data])
|
|
61
|
+
const selectedSpanLogs = useMemo(
|
|
62
|
+
() => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
63
|
+
[selectedSpan, traceLogsState.data],
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
const traceMeta = trace ?? traceSummary
|
|
67
|
+
const hasTraceSelection = traceSummary !== null
|
|
68
|
+
const isLoadingTrace = hasTraceSelection && trace === null && traceStatus !== "error"
|
|
69
|
+
|
|
70
|
+
const focusIndicator = focused ? "\u25b8 " : ""
|
|
71
|
+
const headerTitle = `${focusIndicator}TRACE DETAILS`
|
|
72
|
+
const headerRight = traceMeta
|
|
73
|
+
? `${traceMeta.errorCount > 0 ? `${traceMeta.errorCount} errors` : traceMeta.isRunning ? "running" : isLoadingTrace ? "loading" : "healthy"} \u00b7 ${formatDuration(traceMeta.durationMs)}${traceLogCount > 0 ? ` \u00b7 ${traceLogCount} logs` : ""}`
|
|
74
|
+
: traceStatus === "error"
|
|
75
|
+
? "trace unavailable"
|
|
76
|
+
: "waiting for trace"
|
|
77
|
+
const headerColor = isLoadingTrace
|
|
78
|
+
? colors.count
|
|
79
|
+
: traceMeta?.isRunning
|
|
80
|
+
? colors.warning
|
|
81
|
+
: traceMeta && traceMeta.errorCount > 0
|
|
82
|
+
? colors.error
|
|
83
|
+
: colors.passing
|
|
84
|
+
|
|
85
|
+
const dateStr = traceMeta ? `${formatShortDate(traceMeta.startedAt)} ${formatTimestamp(traceMeta.startedAt)}` : ""
|
|
86
|
+
const opLeft = traceMeta?.rootOperationName ?? ""
|
|
87
|
+
const opGap = Math.max(2, contentWidth - opLeft.length - dateStr.length)
|
|
88
|
+
const warningCount = traceMeta?.warnings.length ?? 0
|
|
89
|
+
const firstWarning = traceMeta?.warnings[0] ?? ""
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + TRACE_DETAILS_HEADER_ROWS} overflow="hidden">
|
|
93
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
94
|
+
<AlignedHeaderLine left={headerTitle} right={headerRight} width={contentWidth} rightFg={headerColor} />
|
|
95
|
+
</box>
|
|
96
|
+
{trace ? (
|
|
97
|
+
<>
|
|
98
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
99
|
+
<TextLine>
|
|
100
|
+
<span>{opLeft}</span>
|
|
101
|
+
<span>{" ".repeat(opGap)}</span>
|
|
102
|
+
<span fg={colors.muted}>{dateStr}</span>
|
|
103
|
+
</TextLine>
|
|
104
|
+
{warningCount > 0 ? (
|
|
105
|
+
<TextLine>
|
|
106
|
+
<span fg={colors.defaultService}>{trace.serviceName}</span>
|
|
107
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
108
|
+
<span fg={colors.count}>{trace.spanCount} spans</span>
|
|
109
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
110
|
+
<span fg={colors.error}>{warningCount} warning{warningCount === 1 ? "" : "s"}: {firstWarning}</span>
|
|
111
|
+
</TextLine>
|
|
112
|
+
) : (
|
|
113
|
+
<TextLine>
|
|
114
|
+
<span fg={colors.defaultService}>{trace.serviceName}</span>
|
|
115
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
116
|
+
<span fg={colors.count}>{trace.spanCount} spans</span>
|
|
117
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
118
|
+
<span fg={colors.muted}>{trace.traceId.slice(0, 16)}</span>
|
|
119
|
+
</TextLine>
|
|
120
|
+
)}
|
|
121
|
+
</box>
|
|
122
|
+
<Divider width={paneWidth} />
|
|
123
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
124
|
+
<WaterfallTimeline
|
|
125
|
+
trace={trace}
|
|
126
|
+
filteredSpans={filteredSpans}
|
|
127
|
+
spanLogCounts={spanLogCounts}
|
|
128
|
+
selectedSpanLogs={selectedSpanLogs}
|
|
129
|
+
contentWidth={contentWidth}
|
|
130
|
+
bodyLines={bodyLines}
|
|
131
|
+
selectedSpanIndex={selectedSpanIndex}
|
|
132
|
+
collapsedSpanIds={collapsedSpanIds}
|
|
133
|
+
onSelectSpan={onSelectSpan}
|
|
134
|
+
/>
|
|
135
|
+
</box>
|
|
136
|
+
</>
|
|
137
|
+
) : isLoadingTrace && traceMeta ? (
|
|
138
|
+
<>
|
|
139
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
140
|
+
<TextLine>
|
|
141
|
+
<span>{opLeft}</span>
|
|
142
|
+
<span>{" ".repeat(opGap)}</span>
|
|
143
|
+
<span fg={colors.muted}>{dateStr}</span>
|
|
144
|
+
</TextLine>
|
|
145
|
+
<TextLine>
|
|
146
|
+
<span fg={colors.defaultService}>{traceMeta.serviceName}</span>
|
|
147
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
148
|
+
<span fg={colors.count}>{traceMeta.spanCount} spans</span>
|
|
149
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
150
|
+
<span fg={colors.count}>warming adjacent trace...</span>
|
|
151
|
+
</TextLine>
|
|
152
|
+
</box>
|
|
153
|
+
<Divider width={paneWidth} />
|
|
154
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
155
|
+
<PlainLine text="Loading trace details..." fg={colors.count} />
|
|
156
|
+
</box>
|
|
157
|
+
</>
|
|
158
|
+
) : hasTraceSelection && traceStatus === "error" ? (
|
|
159
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
160
|
+
<PlainLine text={traceError ?? "Could not load trace."} fg={colors.error} />
|
|
161
|
+
</box>
|
|
162
|
+
) : (
|
|
163
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
164
|
+
<PlainLine text="No trace selected. Use j/k in the trace list." fg={colors.muted} />
|
|
165
|
+
</box>
|
|
166
|
+
)}
|
|
167
|
+
</box>
|
|
168
|
+
)
|
|
169
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import { memo } from "react"
|
|
3
|
+
import { config } from "../config.ts"
|
|
4
|
+
import type { TraceSummaryItem } from "../domain.ts"
|
|
5
|
+
import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
|
|
6
|
+
import { PlainLine, TextLine } from "./primitives.tsx"
|
|
7
|
+
import type { LoadStatus } from "./state.ts"
|
|
8
|
+
import { colors } from "./theme.ts"
|
|
9
|
+
|
|
10
|
+
const getTraceRowLayout = (contentWidth: number) => {
|
|
11
|
+
const stateWidth = 1
|
|
12
|
+
const durationWidth = 8
|
|
13
|
+
const countWidth = 6
|
|
14
|
+
const ageWidth = 4
|
|
15
|
+
// Keep the operation column intentionally capped so the metrics cluster stays
|
|
16
|
+
// visually closer, but still allow common operation names to fit cleanly.
|
|
17
|
+
const titleWidth = Math.min(20, Math.max(8, contentWidth - stateWidth - durationWidth - countWidth - ageWidth - 2))
|
|
18
|
+
return { stateWidth, durationWidth, countWidth, ageWidth, titleWidth }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const fitTraceTitle = (text: string, width: number) => {
|
|
22
|
+
if (width <= 0) return ""
|
|
23
|
+
return text.length <= width ? text.padEnd(width, " ") : text.slice(0, width)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const TraceRow = ({
|
|
27
|
+
trace,
|
|
28
|
+
selected,
|
|
29
|
+
contentWidth,
|
|
30
|
+
onSelect,
|
|
31
|
+
}: {
|
|
32
|
+
trace: TraceSummaryItem
|
|
33
|
+
selected: boolean
|
|
34
|
+
contentWidth: number
|
|
35
|
+
onSelect: () => void
|
|
36
|
+
}) => {
|
|
37
|
+
const { stateWidth, durationWidth, countWidth, ageWidth, titleWidth } = getTraceRowLayout(contentWidth)
|
|
38
|
+
const title = trace.isRunning
|
|
39
|
+
? `${trace.rootOperationName} [${lifecycleLabel(trace)}]`
|
|
40
|
+
: trace.rootOperationName
|
|
41
|
+
const titleColor = selected ? colors.selectedText : trace.isRunning ? colors.warning : colors.text
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<box id={traceRowId(trace.traceId)} height={1} onMouseDown={onSelect}>
|
|
45
|
+
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
46
|
+
<span fg={traceIndicatorColor(trace)}>{fitCell(traceIndicator(trace), stateWidth)}</span>
|
|
47
|
+
<span> </span>
|
|
48
|
+
<span fg={titleColor}>{fitTraceTitle(title, titleWidth)}</span>
|
|
49
|
+
<span fg={selected ? colors.accent : colors.count}>{fitCell(trace.durationMs >= 1 ? formatDuration(trace.durationMs) : "", durationWidth, "right")}</span>
|
|
50
|
+
<span> </span>
|
|
51
|
+
<span fg={colors.muted}>{fitCell(`${trace.spanCount}sp`, countWidth, "right")}</span>
|
|
52
|
+
<span> </span>
|
|
53
|
+
<span fg={colors.muted}>{fitCell(relativeTime(trace.startedAt), ageWidth, "right")}</span>
|
|
54
|
+
</TextLine>
|
|
55
|
+
</box>
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface TraceListProps {
|
|
60
|
+
readonly traces: readonly TraceSummaryItem[]
|
|
61
|
+
readonly selectedTraceId: string | null
|
|
62
|
+
readonly status: LoadStatus
|
|
63
|
+
readonly error: string | null
|
|
64
|
+
readonly contentWidth: number
|
|
65
|
+
readonly services: readonly string[]
|
|
66
|
+
readonly selectedService: string | null
|
|
67
|
+
readonly focused?: boolean
|
|
68
|
+
readonly filterText?: string
|
|
69
|
+
readonly sortMode?: string
|
|
70
|
+
readonly totalCount?: number
|
|
71
|
+
readonly onSelectTrace: (traceId: string) => void
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export const TraceList = ({
|
|
75
|
+
showHeader,
|
|
76
|
+
traces,
|
|
77
|
+
selectedTraceId,
|
|
78
|
+
status,
|
|
79
|
+
error,
|
|
80
|
+
contentWidth,
|
|
81
|
+
services,
|
|
82
|
+
selectedService,
|
|
83
|
+
focused = true,
|
|
84
|
+
filterText,
|
|
85
|
+
sortMode,
|
|
86
|
+
totalCount,
|
|
87
|
+
onSelectTrace,
|
|
88
|
+
}: { showHeader: boolean } & TraceListProps) => {
|
|
89
|
+
if (showHeader) {
|
|
90
|
+
const countLabel = totalCount !== undefined && totalCount !== traces.length ? `${traces.length}/${totalCount}` : traces.length > 0 ? String(traces.length) : ""
|
|
91
|
+
const metaLabel = [
|
|
92
|
+
filterText ? `filter: ${filterText}` : null,
|
|
93
|
+
sortMode && sortMode !== "recent" ? `sort: ${sortMode}` : null,
|
|
94
|
+
].filter((part): part is string => part !== null).join(" · ")
|
|
95
|
+
const serviceLabel = services.length > 1 && selectedService
|
|
96
|
+
? `${services.length} services`
|
|
97
|
+
: ""
|
|
98
|
+
const leftLabel = `TRACES${countLabel ? ` ${countLabel}` : ""}${metaLabel ? ` · ${metaLabel}` : ""}`
|
|
99
|
+
const gap = Math.max(2, contentWidth - leftLabel.length - serviceLabel.length)
|
|
100
|
+
return (
|
|
101
|
+
<TextLine>
|
|
102
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>TRACES</span>
|
|
103
|
+
{countLabel ? <span fg={colors.muted}>{` ${countLabel}`}</span> : null}
|
|
104
|
+
{metaLabel ? <span fg={colors.muted}>{` · ${metaLabel}`}</span> : null}
|
|
105
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
106
|
+
<span fg={colors.muted}>{serviceLabel}</span>
|
|
107
|
+
</TextLine>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return (
|
|
112
|
+
<box flexDirection="column">
|
|
113
|
+
{status === "loading" && traces.length === 0 ? <PlainLine text="Loading traces..." fg={colors.muted} /> : null}
|
|
114
|
+
{status === "error" ? <PlainLine text={error ?? "Could not load traces."} fg={colors.error} /> : null}
|
|
115
|
+
{status === "ready" && services.length === 0 ? <PlainLine text="No services reporting yet. Start your app and emit a span." fg={colors.muted} /> : null}
|
|
116
|
+
{status === "ready" && selectedService && traces.length === 0 ? <PlainLine text="No traces in the current lookback window." fg={colors.muted} /> : null}
|
|
117
|
+
{traces.map((trace) => (
|
|
118
|
+
<TraceRow
|
|
119
|
+
key={trace.traceId}
|
|
120
|
+
trace={trace}
|
|
121
|
+
selected={trace.traceId === selectedTraceId}
|
|
122
|
+
contentWidth={contentWidth}
|
|
123
|
+
onSelect={() => onSelectTrace(trace.traceId)}
|
|
124
|
+
/>
|
|
125
|
+
))}
|
|
126
|
+
</box>
|
|
127
|
+
)
|
|
128
|
+
}
|