@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.
Files changed (53) hide show
  1. package/AGENTS.md +142 -0
  2. package/LICENSE +21 -0
  3. package/README.md +199 -0
  4. package/package.json +92 -0
  5. package/src/App.tsx +217 -0
  6. package/src/cli.ts +258 -0
  7. package/src/config.ts +39 -0
  8. package/src/daemon.test.ts +59 -0
  9. package/src/daemon.ts +398 -0
  10. package/src/domain.ts +233 -0
  11. package/src/httpApi.ts +384 -0
  12. package/src/index.tsx +18 -0
  13. package/src/instructions.ts +72 -0
  14. package/src/localServer.ts +699 -0
  15. package/src/locator.ts +138 -0
  16. package/src/mcp.ts +260 -0
  17. package/src/motel.ts +86 -0
  18. package/src/motelClient.ts +201 -0
  19. package/src/otlp.ts +142 -0
  20. package/src/queryFilters.ts +39 -0
  21. package/src/registry.ts +86 -0
  22. package/src/runtime.ts +38 -0
  23. package/src/server.ts +10 -0
  24. package/src/services/LogQueryService.ts +43 -0
  25. package/src/services/TelemetryStore.ts +1821 -0
  26. package/src/services/TraceQueryService.ts +71 -0
  27. package/src/telemetry.test.ts +726 -0
  28. package/src/ui/ServiceLogs.tsx +112 -0
  29. package/src/ui/SpanDetail.tsx +134 -0
  30. package/src/ui/SpanDetailFull.tsx +224 -0
  31. package/src/ui/SpanDetailPane.tsx +91 -0
  32. package/src/ui/TraceDetailsPane.tsx +169 -0
  33. package/src/ui/TraceList.tsx +128 -0
  34. package/src/ui/Waterfall.tsx +412 -0
  35. package/src/ui/app/TraceListPane.tsx +34 -0
  36. package/src/ui/app/TraceWorkspace.tsx +254 -0
  37. package/src/ui/app/useAppLayout.ts +79 -0
  38. package/src/ui/app/useTraceScreenData.ts +411 -0
  39. package/src/ui/format.ts +119 -0
  40. package/src/ui/primitives.tsx +170 -0
  41. package/src/ui/state.ts +137 -0
  42. package/src/ui/theme.ts +153 -0
  43. package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
  44. package/src/ui/traceSortNav.repro.seed.ts +62 -0
  45. package/src/ui/traceSortNav.repro.test.ts +220 -0
  46. package/src/ui/useKeyboardNav.ts +532 -0
  47. package/src/ui/waterfallNav.repro.seed.ts +86 -0
  48. package/src/ui/waterfallNav.repro.test.ts +263 -0
  49. package/src/ui/waterfallNav.test.ts +422 -0
  50. package/src/ui/waterfallNav.ts +75 -0
  51. package/web/dist/assets/index-BEKIiisE.js +27 -0
  52. package/web/dist/assets/index-DzuHNBGV.css +2 -0
  53. 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
+ }