@kitlangton/motel 0.1.3 → 0.2.1
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 +11 -1
- package/package.json +5 -3
- package/src/App.tsx +239 -59
- package/src/daemon.test.ts +144 -7
- package/src/daemon.ts +113 -8
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +62 -4
- package/src/httpApi.ts +4 -1
- package/src/localServer.ts +112 -121
- package/src/mcp.ts +172 -0
- package/src/motelClient.ts +166 -14
- package/src/registry.ts +26 -23
- package/src/runtime.ts +8 -2
- package/src/server.ts +10 -9
- package/src/services/AsyncIngest.ts +52 -0
- package/src/services/TelemetryStore.ts +285 -27
- package/src/services/TraceQueryService.ts +4 -2
- package/src/services/ingestRpc.ts +41 -0
- package/src/services/telemetryWorker.ts +62 -0
- package/src/storybook/aiChatStory.tsx +243 -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 +61 -0
- package/src/ui/AiChatView.tsx +292 -0
- package/src/ui/SpanContentView.tsx +181 -0
- package/src/ui/SpanDetail.tsx +98 -17
- package/src/ui/TraceDetailsPane.tsx +35 -3
- package/src/ui/Waterfall.tsx +94 -167
- package/src/ui/aiChatModel.test.ts +347 -0
- package/src/ui/aiChatModel.ts +736 -0
- package/src/ui/aiState.ts +71 -0
- package/src/ui/app/TraceWorkspace.tsx +295 -120
- package/src/ui/app/useAppLayout.ts +14 -11
- package/src/ui/app/useTraceScreenData.ts +191 -35
- package/src/ui/atoms.ts +131 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/loaders.ts +120 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +47 -21
- package/src/ui/state.ts +4 -169
- package/src/ui/useAttrFilterPicker.ts +63 -23
- package/src/ui/useKeyboardNav.ts +576 -300
- 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.test.ts +17 -1
- package/src/ui/waterfallNav.ts +1 -1
- package/web/dist/assets/{index-DKinj-OE.js → index-DnyVo03x.js} +1 -1
- package/web/dist/index.html +1 -1
package/src/ui/SpanDetail.tsx
CHANGED
|
@@ -4,6 +4,50 @@ import { formatTimestamp, logSeverityColor, relevantLogAttributes, truncateText,
|
|
|
4
4
|
import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
|
|
5
5
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
6
6
|
|
|
7
|
+
/**
|
|
8
|
+
* Inline-vs-stacked threshold for tag rendering.
|
|
9
|
+
*
|
|
10
|
+
* A tag renders **inline** (`key value` on one row) when:
|
|
11
|
+
* - the key is ≤ INLINE_KEY_MAX chars AND
|
|
12
|
+
* - the value fits in the remaining width on one line AND
|
|
13
|
+
* - the value contains no newlines.
|
|
14
|
+
*
|
|
15
|
+
* Otherwise it **stacks** — the key gets its own row (full, no truncation)
|
|
16
|
+
* and the value is wrapped below with a leading indent. Long LLM payloads
|
|
17
|
+
* (`ai.prompt.messages`, `gen_ai.completion`, etc.) always hit the stacked
|
|
18
|
+
* path, which is what makes them readable at a glance.
|
|
19
|
+
*/
|
|
20
|
+
const INLINE_KEY_MAX = 24
|
|
21
|
+
/** Max wrapped rows we'll spend on a single stacked value's content. */
|
|
22
|
+
const VALUE_WRAP_MAX_LINES = 4
|
|
23
|
+
/** Leading indent for stacked values — subtle but visible. */
|
|
24
|
+
const STACK_INDENT = " "
|
|
25
|
+
|
|
26
|
+
interface TagRender {
|
|
27
|
+
readonly key: string
|
|
28
|
+
readonly value: string
|
|
29
|
+
readonly inline: boolean
|
|
30
|
+
readonly valueLines: readonly string[]
|
|
31
|
+
readonly rowCount: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const planTag = (key: string, value: string, contentWidth: number, inlineKeyPad: number): TagRender => {
|
|
35
|
+
const hasNewline = value.includes("\n")
|
|
36
|
+
const inlineValueWidth = Math.max(1, contentWidth - inlineKeyPad - 2)
|
|
37
|
+
const canInline =
|
|
38
|
+
!hasNewline &&
|
|
39
|
+
key.length <= INLINE_KEY_MAX &&
|
|
40
|
+
value.length <= inlineValueWidth
|
|
41
|
+
if (canInline) {
|
|
42
|
+
return { key, value, inline: true, valueLines: [value], rowCount: 1 }
|
|
43
|
+
}
|
|
44
|
+
const wrapWidth = Math.max(16, contentWidth - STACK_INDENT.length)
|
|
45
|
+
const valueLines = wrapTextLines(value, wrapWidth, VALUE_WRAP_MAX_LINES)
|
|
46
|
+
// 1 row for the key + N rows for wrapped value (at least 1).
|
|
47
|
+
const rowCount = 1 + Math.max(1, valueLines.length)
|
|
48
|
+
return { key, value, inline: false, valueLines, rowCount }
|
|
49
|
+
}
|
|
50
|
+
|
|
7
51
|
export const SpanDetailView = ({
|
|
8
52
|
span,
|
|
9
53
|
logs,
|
|
@@ -16,15 +60,41 @@ export const SpanDetailView = ({
|
|
|
16
60
|
bodyLines: number
|
|
17
61
|
}) => {
|
|
18
62
|
const tagEntries = Object.entries(span.tags)
|
|
19
|
-
|
|
63
|
+
// Column width for inline keys. We cap at INLINE_KEY_MAX so a single
|
|
64
|
+
// very-long key doesn't widen the column for everyone. Short keys still
|
|
65
|
+
// align against each other.
|
|
66
|
+
const inlineKeyPad = Math.min(
|
|
67
|
+
INLINE_KEY_MAX,
|
|
68
|
+
tagEntries.reduce((max, [key]) => (key.length <= INLINE_KEY_MAX ? Math.max(max, key.length) : max), 0),
|
|
69
|
+
)
|
|
70
|
+
|
|
20
71
|
const maxLogLines = logs.length > 0 ? Math.min(4, Math.max(1, Math.floor(bodyLines * 0.3))) : 0
|
|
21
72
|
const visibleLogs = logs.slice(0, maxLogLines)
|
|
22
73
|
const visibleWarnings = span.warnings.slice(0, visibleLogs.length > 0 ? 1 : 2)
|
|
23
74
|
const visibleEvents = span.events.slice(0, 2)
|
|
24
75
|
const reservedForWarnings = visibleWarnings.length > 0 ? visibleWarnings.length + 2 : 0
|
|
25
76
|
const reservedForEvents = visibleEvents.length > 0 ? visibleEvents.length + 2 : 0
|
|
26
|
-
const reservedForLogs = visibleLogs.length > 0
|
|
27
|
-
|
|
77
|
+
const reservedForLogs = visibleLogs.length > 0
|
|
78
|
+
? visibleLogs.reduce((total, log) => total + 3 + Math.min(3, wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3).length), 1)
|
|
79
|
+
: 0
|
|
80
|
+
// Budget for the TAGS section: total body minus header (TAGS row + blank)
|
|
81
|
+
// minus every other section's reservation. Each stacked tag spends more
|
|
82
|
+
// rows than an inline one so we plan the full visible set up-front rather
|
|
83
|
+
// than slicing by entry count.
|
|
84
|
+
const tagBudget = Math.max(0, bodyLines - 2 - reservedForWarnings - reservedForEvents - reservedForLogs)
|
|
85
|
+
|
|
86
|
+
const planned: TagRender[] = []
|
|
87
|
+
let rowsUsed = 0
|
|
88
|
+
let skipped = 0
|
|
89
|
+
for (const [key, value] of tagEntries) {
|
|
90
|
+
const plan = planTag(key, value, contentWidth, inlineKeyPad)
|
|
91
|
+
if (rowsUsed + plan.rowCount > tagBudget) {
|
|
92
|
+
skipped = tagEntries.length - planned.length
|
|
93
|
+
break
|
|
94
|
+
}
|
|
95
|
+
planned.push(plan)
|
|
96
|
+
rowsUsed += plan.rowCount
|
|
97
|
+
}
|
|
28
98
|
|
|
29
99
|
// NOTE: op name, service, duration, lifecycle, status, and spanId are all
|
|
30
100
|
// rendered by the enclosing SpanDetailPane header (rows 0..2). Starting
|
|
@@ -37,21 +107,32 @@ export const SpanDetailView = ({
|
|
|
37
107
|
<TextLine>
|
|
38
108
|
<span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
|
|
39
109
|
</TextLine>
|
|
40
|
-
{
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
<span fg={colors.
|
|
110
|
+
{planned.map((tag) => tag.inline ? (
|
|
111
|
+
<TextLine key={tag.key}>
|
|
112
|
+
<span fg={colors.count}>{tag.key.padEnd(inlineKeyPad)}</span>
|
|
113
|
+
<span fg={colors.muted}> </span>
|
|
114
|
+
<span fg={colors.text}>{tag.value}</span>
|
|
115
|
+
</TextLine>
|
|
116
|
+
) : (
|
|
117
|
+
<box key={tag.key} flexDirection="column">
|
|
118
|
+
<TextLine>
|
|
119
|
+
<span fg={colors.count}>{tag.key}</span>
|
|
50
120
|
</TextLine>
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
121
|
+
{tag.valueLines.length === 0 ? (
|
|
122
|
+
<TextLine>
|
|
123
|
+
<span fg={colors.muted}>{STACK_INDENT}</span>
|
|
124
|
+
<span fg={colors.muted}>(empty)</span>
|
|
125
|
+
</TextLine>
|
|
126
|
+
) : tag.valueLines.map((line, index) => (
|
|
127
|
+
<TextLine key={index}>
|
|
128
|
+
<span fg={colors.muted}>{STACK_INDENT}</span>
|
|
129
|
+
<span fg={colors.text}>{line}</span>
|
|
130
|
+
</TextLine>
|
|
131
|
+
))}
|
|
132
|
+
</box>
|
|
133
|
+
))}
|
|
134
|
+
{skipped > 0 ? (
|
|
135
|
+
<PlainLine text={`${STACK_INDENT}\u2026 ${skipped} more`} fg={colors.muted} />
|
|
55
136
|
) : null}
|
|
56
137
|
</>
|
|
57
138
|
) : (
|
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { useMemo } from "react"
|
|
2
2
|
import type { TraceItem, TraceSummaryItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, formatShortDate, formatTimestamp } from "./format.ts"
|
|
4
|
-
import { AlignedHeaderLine, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
5
|
-
import {
|
|
4
|
+
import { AlignedHeaderLine, Divider, FilterBar, PlainLine, TextLine } from "./primitives.tsx"
|
|
5
|
+
import { WaterfallTimeline } from "./Waterfall.tsx"
|
|
6
|
+
import { computeMatchingSpanIds } from "./waterfallFilter.ts"
|
|
7
|
+
import { getVisibleSpans } from "./waterfallModel.ts"
|
|
6
8
|
import type { LoadStatus, LogState } from "./state.ts"
|
|
7
9
|
import { colors, SEPARATOR } from "./theme.ts"
|
|
8
10
|
|
|
@@ -30,6 +32,8 @@ export const TraceDetailsPane = ({
|
|
|
30
32
|
collapsedSpanIds,
|
|
31
33
|
focused = false,
|
|
32
34
|
onSelectSpan,
|
|
35
|
+
waterfallFilterMode,
|
|
36
|
+
waterfallFilterText,
|
|
33
37
|
}: {
|
|
34
38
|
trace: TraceItem | null
|
|
35
39
|
traceSummary: TraceSummaryItem | null
|
|
@@ -43,6 +47,8 @@ export const TraceDetailsPane = ({
|
|
|
43
47
|
collapsedSpanIds: ReadonlySet<string>
|
|
44
48
|
focused?: boolean
|
|
45
49
|
onSelectSpan: (index: number) => void
|
|
50
|
+
waterfallFilterMode: boolean
|
|
51
|
+
waterfallFilterText: string
|
|
46
52
|
}) => {
|
|
47
53
|
const filteredSpans = useMemo(
|
|
48
54
|
() => trace ? getVisibleSpans(trace.spans, collapsedSpanIds) : [],
|
|
@@ -62,6 +68,15 @@ export const TraceDetailsPane = ({
|
|
|
62
68
|
() => selectedSpan ? traceLogsState.data.filter((log) => log.spanId === selectedSpan.spanId) : [],
|
|
63
69
|
[selectedSpan, traceLogsState.data],
|
|
64
70
|
)
|
|
71
|
+
const matchingSpanIds = useMemo(
|
|
72
|
+
() => trace ? computeMatchingSpanIds(trace.spans, waterfallFilterText) : null,
|
|
73
|
+
[trace, waterfallFilterText],
|
|
74
|
+
)
|
|
75
|
+
const matchCount = matchingSpanIds?.size ?? 0
|
|
76
|
+
// Reserve 1 row for the filter bar when it's being shown so the
|
|
77
|
+
// waterfall doesn't spill into the footer.
|
|
78
|
+
const showFilterBar = waterfallFilterMode || waterfallFilterText.length > 0
|
|
79
|
+
const waterfallBodyLines = showFilterBar ? Math.max(1, bodyLines - 1) : bodyLines
|
|
65
80
|
|
|
66
81
|
const traceMeta = trace ?? traceSummary
|
|
67
82
|
const hasTraceSelection = traceSummary !== null
|
|
@@ -112,6 +127,22 @@ export const TraceDetailsPane = ({
|
|
|
112
127
|
</TextLine>
|
|
113
128
|
</box>
|
|
114
129
|
<Divider width={paneWidth} />
|
|
130
|
+
{showFilterBar ? (
|
|
131
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
132
|
+
{waterfallFilterMode ? (
|
|
133
|
+
<FilterBar text={waterfallFilterText} width={contentWidth} />
|
|
134
|
+
) : (
|
|
135
|
+
<TextLine>
|
|
136
|
+
<span fg={colors.muted}>{"/"}</span>
|
|
137
|
+
<span fg={colors.text}>{waterfallFilterText}</span>
|
|
138
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
139
|
+
<span fg={colors.count}>{matchCount} match{matchCount === 1 ? "" : "es"}</span>
|
|
140
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
141
|
+
<span fg={colors.muted}>esc clear</span>
|
|
142
|
+
</TextLine>
|
|
143
|
+
)}
|
|
144
|
+
</box>
|
|
145
|
+
) : null}
|
|
115
146
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
116
147
|
<WaterfallTimeline
|
|
117
148
|
trace={trace}
|
|
@@ -119,9 +150,10 @@ export const TraceDetailsPane = ({
|
|
|
119
150
|
spanLogCounts={spanLogCounts}
|
|
120
151
|
selectedSpanLogs={selectedSpanLogs}
|
|
121
152
|
contentWidth={contentWidth}
|
|
122
|
-
bodyLines={
|
|
153
|
+
bodyLines={waterfallBodyLines}
|
|
123
154
|
selectedSpanIndex={selectedSpanIndex}
|
|
124
155
|
collapsedSpanIds={collapsedSpanIds}
|
|
156
|
+
matchingSpanIds={matchingSpanIds}
|
|
125
157
|
onSelectSpan={onSelectSpan}
|
|
126
158
|
/>
|
|
127
159
|
</box>
|
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -1,81 +1,17 @@
|
|
|
1
1
|
import { memo, useLayoutEffect, useRef, useState } from "react"
|
|
2
|
-
import type
|
|
2
|
+
import { isAiSpan, type LogItem, type TraceItem, type TraceSpanItem } from "../domain.ts"
|
|
3
3
|
import { formatDuration, lifecycleLabel, splitDuration, truncateText } from "./format.ts"
|
|
4
4
|
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
5
5
|
import { colors, waterfallColors } from "./theme.ts"
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
result.push(span)
|
|
16
|
-
if (collapsedIds.has(span.spanId)) {
|
|
17
|
-
skipDepth = span.depth
|
|
18
|
-
}
|
|
19
|
-
}
|
|
20
|
-
return result
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Find the index of a span's parent in the visible list. */
|
|
24
|
-
export const findParentIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
25
|
-
const span = spans[index]
|
|
26
|
-
if (!span || span.depth === 0) return null
|
|
27
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
28
|
-
if (spans[i]!.depth < span.depth) return i
|
|
29
|
-
}
|
|
30
|
-
return null
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
/** Find the index of a span's first child in the visible list. */
|
|
34
|
-
export const findFirstChildIndex = (spans: readonly TraceSpanItem[], index: number): number | null => {
|
|
35
|
-
const span = spans[index]
|
|
36
|
-
const next = spans[index + 1]
|
|
37
|
-
if (span && next && next.depth > span.depth) return index + 1
|
|
38
|
-
return null
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const INTERESTING_TAGS = [
|
|
42
|
-
"http.method", "http.url", "http.status_code", "http.route",
|
|
43
|
-
"db.system", "db.statement", "db.name",
|
|
44
|
-
"messaging.system", "messaging.destination",
|
|
45
|
-
"error", "error.message",
|
|
46
|
-
"net.peer.name", "net.peer.port",
|
|
47
|
-
] as const
|
|
48
|
-
|
|
49
|
-
const buildTreePrefix = (spans: readonly TraceSpanItem[], index: number): string => {
|
|
50
|
-
const span = spans[index]
|
|
51
|
-
if (span.depth === 0) return ""
|
|
52
|
-
|
|
53
|
-
const parts: string[] = []
|
|
54
|
-
|
|
55
|
-
const isLastChild = (spanIndex: number, depth: number): boolean => {
|
|
56
|
-
for (let i = spanIndex + 1; i < spans.length; i++) {
|
|
57
|
-
if (spans[i].depth < depth) return true
|
|
58
|
-
if (spans[i].depth === depth) return false
|
|
59
|
-
}
|
|
60
|
-
return true
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
parts.push(isLastChild(index, span.depth) ? "\u2514\u2500" : "\u251c\u2500")
|
|
64
|
-
|
|
65
|
-
for (let d = span.depth - 1; d >= 1; d--) {
|
|
66
|
-
let parentIndex = index
|
|
67
|
-
for (let i = index - 1; i >= 0; i--) {
|
|
68
|
-
if (spans[i].depth === d) {
|
|
69
|
-
parentIndex = i
|
|
70
|
-
break
|
|
71
|
-
}
|
|
72
|
-
if (spans[i].depth < d) break
|
|
73
|
-
}
|
|
74
|
-
parts.push(isLastChild(parentIndex, d) ? " " : "\u2502 ")
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return parts.reverse().join("")
|
|
78
|
-
}
|
|
6
|
+
export { getVisibleSpans } from "./waterfallModel.ts"
|
|
7
|
+
import {
|
|
8
|
+
buildTreePrefix,
|
|
9
|
+
findFirstChildIndex,
|
|
10
|
+
getWaterfallLayout,
|
|
11
|
+
getWaterfallSuffixMetrics,
|
|
12
|
+
type WaterfallSuffixMetrics,
|
|
13
|
+
spanPreviewEntries,
|
|
14
|
+
} from "./waterfallModel.ts"
|
|
79
15
|
|
|
80
16
|
const PARTIAL_BLOCKS = ["", "\u258f", "\u258e", "\u258d", "\u258c", "\u258b", "\u258a", "\u2589", "\u2588"] as const
|
|
81
17
|
const ULTRA_SHORT_MARKERS = ["\u258f", "\u258e", "\u258d", "\u258c"] as const
|
|
@@ -180,66 +116,6 @@ const durationColor = (durationMs: number) => {
|
|
|
180
116
|
return colors.muted
|
|
181
117
|
}
|
|
182
118
|
|
|
183
|
-
export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
|
|
184
|
-
const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
|
|
185
|
-
// Two single-space gaps: one between label and bar, one between bar and suffix.
|
|
186
|
-
const barWidth = Math.max(6, contentWidth - labelMaxWidth - suffixWidth - 2)
|
|
187
|
-
return { labelMaxWidth, barWidth } as const
|
|
188
|
-
}
|
|
189
|
-
|
|
190
|
-
export type WaterfallSuffixMetrics = {
|
|
191
|
-
readonly maxDurationWidth: number
|
|
192
|
-
readonly suffixWidth: number
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
/**
|
|
196
|
-
* Compute a shared suffix (duration) width from the visible viewport.
|
|
197
|
-
* Reserving the width once keeps every row's duration right-aligned on the
|
|
198
|
-
* same column regardless of per-row content. Log correlation lives in the
|
|
199
|
-
* span detail pane, not the row suffix.
|
|
200
|
-
*/
|
|
201
|
-
export const getWaterfallSuffixMetrics = (
|
|
202
|
-
spans: readonly { readonly durationMs: number; readonly spanId: string }[],
|
|
203
|
-
): WaterfallSuffixMetrics => {
|
|
204
|
-
let maxDurationWidth = 0
|
|
205
|
-
for (const span of spans) {
|
|
206
|
-
const d = formatDuration(Math.max(0, span.durationMs)).length
|
|
207
|
-
if (d > maxDurationWidth) maxDurationWidth = d
|
|
208
|
-
}
|
|
209
|
-
return { maxDurationWidth, suffixWidth: maxDurationWidth }
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// Retained for tests: per-row view of the shared layout.
|
|
213
|
-
export const getWaterfallColumns = (
|
|
214
|
-
contentWidth: number,
|
|
215
|
-
metrics: WaterfallSuffixMetrics,
|
|
216
|
-
) => {
|
|
217
|
-
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, metrics.suffixWidth)
|
|
218
|
-
return { labelMaxWidth, barWidth, suffixWidth: metrics.suffixWidth } as const
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
222
|
-
const entries = Object.entries(span.tags)
|
|
223
|
-
const interesting = entries.filter(([key]) =>
|
|
224
|
-
INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) || key.startsWith("error"),
|
|
225
|
-
)
|
|
226
|
-
const rest = entries.filter(([key]) =>
|
|
227
|
-
!INTERESTING_TAGS.includes(key as (typeof INTERESTING_TAGS)[number]) && !key.startsWith("error") && !key.startsWith("otel.") && key !== "span.kind",
|
|
228
|
-
)
|
|
229
|
-
const tagResults: Array<{ key: string; value: string; isWarning?: boolean }> = []
|
|
230
|
-
if (logs.length > 0) {
|
|
231
|
-
tagResults.push({ key: "logs", value: `${logs.length} correlated` })
|
|
232
|
-
tagResults.push({ key: "log", value: logs[0]!.body.replace(/\s+/g, " ") })
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
tagResults.push(...[...interesting, ...rest]
|
|
236
|
-
.slice(0, maxEntries - span.warnings.length)
|
|
237
|
-
.map(([key, value]) => ({ key, value })))
|
|
238
|
-
for (const warning of span.warnings) {
|
|
239
|
-
tagResults.push({ key: "warning", value: warning, isWarning: true })
|
|
240
|
-
}
|
|
241
|
-
return tagResults.slice(0, maxEntries)
|
|
242
|
-
}
|
|
243
119
|
|
|
244
120
|
const WaterfallRow = memo(({
|
|
245
121
|
span,
|
|
@@ -251,6 +127,7 @@ const WaterfallRow = memo(({
|
|
|
251
127
|
collapsed,
|
|
252
128
|
hasChildSpans,
|
|
253
129
|
suffixMetrics,
|
|
130
|
+
dimmed,
|
|
254
131
|
onSelect,
|
|
255
132
|
}: {
|
|
256
133
|
span: TraceSpanItem
|
|
@@ -262,17 +139,37 @@ const WaterfallRow = memo(({
|
|
|
262
139
|
collapsed: boolean
|
|
263
140
|
hasChildSpans: boolean
|
|
264
141
|
suffixMetrics: WaterfallSuffixMetrics
|
|
142
|
+
dimmed: boolean
|
|
265
143
|
onSelect: () => void
|
|
266
144
|
}) => {
|
|
267
145
|
const prefix = buildTreePrefix(spans, index)
|
|
268
|
-
|
|
269
|
-
|
|
146
|
+
const isAi = isAiSpan(span.tags)
|
|
147
|
+
// Indicator column: `!` on error, chevron on collapsible parents,
|
|
148
|
+
// `✦` on AI leaves (LLM payloads detected — enter drills into a
|
|
149
|
+
// specialized chat view), `·` on other leaves. AI parents keep the
|
|
150
|
+
// chevron glyph so tree structure stays readable; the accent color
|
|
151
|
+
// (applied below) carries the "AI content lives here" signal.
|
|
152
|
+
const indicator = span.status === "error" ? "!"
|
|
153
|
+
: hasChildSpans ? (collapsed ? "\u25b8" : "\u25be")
|
|
154
|
+
: isAi ? "\u2726"
|
|
155
|
+
: "\u00b7"
|
|
270
156
|
const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
|
|
271
157
|
|
|
272
158
|
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
|
|
273
159
|
|
|
274
|
-
|
|
275
|
-
|
|
160
|
+
// Op name budget = labelMaxWidth minus (prefix + indicator + 1 space).
|
|
161
|
+
// Never force a minimum: at very deep nesting or narrow widths the
|
|
162
|
+
// prefix + indicator may already fill the label column, in which
|
|
163
|
+
// case we render the op as an empty string (or a lone ellipsis) so
|
|
164
|
+
// the line stays within contentWidth. Previous code forced op to 4
|
|
165
|
+
// chars which could push total row width past the pane and make
|
|
166
|
+
// OpenTUI smear "..." across the right edge.
|
|
167
|
+
const opMaxWidth = Math.max(0, labelMaxWidth - prefix.length - 2)
|
|
168
|
+
const opTruncated = opMaxWidth === 0
|
|
169
|
+
? ""
|
|
170
|
+
: opName.length > opMaxWidth
|
|
171
|
+
? `${opName.slice(0, Math.max(0, opMaxWidth - 1))}\u2026`
|
|
172
|
+
: opName
|
|
276
173
|
const labelLen = prefix.length + 2 + opTruncated.length
|
|
277
174
|
const labelPad = " ".repeat(Math.max(0, labelMaxWidth - labelLen))
|
|
278
175
|
|
|
@@ -282,12 +179,28 @@ const WaterfallRow = memo(({
|
|
|
282
179
|
const rowBg = selected ? colors.selectedBg : colors.screenBg
|
|
283
180
|
const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
|
|
284
181
|
const bg = selected ? colors.selectedBg : undefined
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
const
|
|
182
|
+
// Dimmed rows (non-matching under an active waterfall filter) collapse
|
|
183
|
+
// their palette to the muted separator color so matches stand out.
|
|
184
|
+
// Selection always wins — the selected row keeps its full brightness
|
|
185
|
+
// so you can still see where the cursor is while scanning.
|
|
186
|
+
const treeColor = selected ? colors.separator : dimmed ? colors.separator : colors.treeLine
|
|
187
|
+
const indicatorColor = selected ? colors.selectedText
|
|
188
|
+
: dimmed ? colors.separator
|
|
189
|
+
: isError ? colors.error
|
|
190
|
+
// AI accent outranks parent/leaf color so both AI parents and AI
|
|
191
|
+
// leaves scan as "there's an LLM payload here" from across the
|
|
192
|
+
// waterfall. Error still wins because a failed AI span is first
|
|
193
|
+
// and foremost a failure.
|
|
194
|
+
: isAi ? colors.accent
|
|
195
|
+
: hasChildSpans ? colors.muted
|
|
196
|
+
: colors.passing
|
|
197
|
+
const opColor = selected ? colors.selectedText
|
|
198
|
+
: dimmed ? colors.separator
|
|
199
|
+
: span.isRunning ? colors.warning
|
|
200
|
+
: colors.text
|
|
201
|
+
|
|
202
|
+
const durationFg = selected ? colors.selectedText : dimmed ? colors.separator : durationColor(span.durationMs)
|
|
203
|
+
const unitFg = dimmed && !selected ? colors.separator : colors.muted
|
|
291
204
|
|
|
292
205
|
// Split the duration so the unit (s/ms) renders dimmer than the number.
|
|
293
206
|
const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
|
|
@@ -373,6 +286,7 @@ export const WaterfallTimeline = ({
|
|
|
373
286
|
bodyLines,
|
|
374
287
|
selectedSpanIndex,
|
|
375
288
|
collapsedSpanIds,
|
|
289
|
+
matchingSpanIds,
|
|
376
290
|
onSelectSpan,
|
|
377
291
|
}: {
|
|
378
292
|
trace: TraceItem
|
|
@@ -383,6 +297,11 @@ export const WaterfallTimeline = ({
|
|
|
383
297
|
bodyLines: number
|
|
384
298
|
selectedSpanIndex: number | null
|
|
385
299
|
collapsedSpanIds: ReadonlySet<string>
|
|
300
|
+
/**
|
|
301
|
+
* When set, spans whose spanId is NOT in this set are dimmed. Null
|
|
302
|
+
* means no filter active — skip the per-row lookup entirely.
|
|
303
|
+
*/
|
|
304
|
+
matchingSpanIds?: ReadonlySet<string> | null
|
|
386
305
|
onSelectSpan: (index: number) => void
|
|
387
306
|
}) => {
|
|
388
307
|
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
@@ -392,29 +311,37 @@ export const WaterfallTimeline = ({
|
|
|
392
311
|
spanIndexById.set(trace.spans[i].spanId, i)
|
|
393
312
|
}
|
|
394
313
|
|
|
395
|
-
// Virtual windowing: only render visible rows
|
|
396
|
-
// the
|
|
314
|
+
// Virtual windowing: only render visible rows. We track scroll offset
|
|
315
|
+
// as state so the mouse wheel can scroll the window INDEPENDENTLY of
|
|
316
|
+
// the selected span (mirrors TraceList behavior). Selection still
|
|
317
|
+
// follows: if the user moves selection off-screen via j/k, we nudge
|
|
318
|
+
// the window to keep it visible — but wheel-scrolling never changes
|
|
319
|
+
// selection, only clicking a row does.
|
|
397
320
|
const viewportSize = Math.max(1, bodyLines)
|
|
398
|
-
const
|
|
321
|
+
const maxOffset = Math.max(0, filteredSpans.length - viewportSize)
|
|
322
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
399
323
|
const lastTraceIdRef = useRef<string | null>(null)
|
|
400
324
|
|
|
401
|
-
// Reset scroll offset when the trace changes
|
|
325
|
+
// Reset scroll offset when the trace changes.
|
|
402
326
|
if (trace.traceId !== lastTraceIdRef.current) {
|
|
403
|
-
|
|
327
|
+
setScrollOffset(0)
|
|
404
328
|
lastTraceIdRef.current = trace.traceId
|
|
405
329
|
}
|
|
406
330
|
|
|
407
|
-
//
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
331
|
+
// Auto-follow selection: only if the selected span would be hidden
|
|
332
|
+
// by the current window, shift just enough to bring it back. Runs in
|
|
333
|
+
// layout effect so the visible window is accurate on the same paint
|
|
334
|
+
// that the selection changed.
|
|
335
|
+
useLayoutEffect(() => {
|
|
336
|
+
if (selectedSpanIndex === null) return
|
|
337
|
+
setScrollOffset((current) => {
|
|
338
|
+
if (selectedSpanIndex < current) return selectedSpanIndex
|
|
339
|
+
if (selectedSpanIndex >= current + viewportSize) return selectedSpanIndex - viewportSize + 1
|
|
340
|
+
return current
|
|
341
|
+
})
|
|
342
|
+
}, [selectedSpanIndex, viewportSize])
|
|
343
|
+
|
|
344
|
+
const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
|
|
418
345
|
const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
|
|
419
346
|
const blankCount = Math.max(0, viewportSize - windowSpans.length)
|
|
420
347
|
|
|
@@ -422,19 +349,17 @@ export const WaterfallTimeline = ({
|
|
|
422
349
|
// row's duration cell lines up on the same right-edge column.
|
|
423
350
|
const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
|
|
424
351
|
|
|
425
|
-
// Mouse wheel
|
|
426
|
-
//
|
|
427
|
-
//
|
|
428
|
-
//
|
|
352
|
+
// Mouse wheel scrolls the window without touching selection — matches
|
|
353
|
+
// the trace list, so the user can browse ahead of their cursor freely
|
|
354
|
+
// and click a row to commit. Delta is scaled 1:1 with opentui's wheel
|
|
355
|
+
// reporting (1 notch ≈ 3 rows on most terminals).
|
|
429
356
|
const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
|
|
430
357
|
const info = event.scroll
|
|
431
358
|
if (!info || filteredSpans.length === 0) return
|
|
432
359
|
const magnitude = Math.max(1, Math.round(info.delta))
|
|
433
360
|
const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
|
|
434
361
|
if (signed === 0) return
|
|
435
|
-
|
|
436
|
-
const next = Math.max(0, Math.min(start + signed, filteredSpans.length - 1))
|
|
437
|
-
if (next !== selectedSpanIndex) onSelectSpan(next)
|
|
362
|
+
setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
|
|
438
363
|
event.stopPropagation?.()
|
|
439
364
|
}
|
|
440
365
|
|
|
@@ -443,6 +368,7 @@ export const WaterfallTimeline = ({
|
|
|
443
368
|
{windowSpans.map((span, index) => {
|
|
444
369
|
const actualIndex = windowStart + index
|
|
445
370
|
const fullIndex = spanIndexById.get(span.spanId) ?? -1
|
|
371
|
+
const dimmed = matchingSpanIds != null && !matchingSpanIds.has(span.spanId)
|
|
446
372
|
return (
|
|
447
373
|
<WaterfallRow
|
|
448
374
|
key={`${trace.traceId}-${span.spanId}`}
|
|
@@ -455,6 +381,7 @@ export const WaterfallTimeline = ({
|
|
|
455
381
|
collapsed={collapsedSpanIds.has(span.spanId)}
|
|
456
382
|
hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
|
|
457
383
|
suffixMetrics={suffixMetrics}
|
|
384
|
+
dimmed={dimmed}
|
|
458
385
|
onSelect={() => onSelectSpan(actualIndex)}
|
|
459
386
|
/>
|
|
460
387
|
)
|