@kitlangton/motel 0.1.0 → 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/README.md +70 -163
- package/package.json +5 -2
- 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 -49
- 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/traceSortNav.repro.test.ts +3 -2
- 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
package/src/ui/Waterfall.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { memo, useRef } from "react"
|
|
1
|
+
import { memo, useLayoutEffect, useRef, useState } from "react"
|
|
2
2
|
import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
|
|
3
|
-
import { formatDuration, lifecycleLabel, truncateText } from "./format.ts"
|
|
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
6
|
|
|
@@ -92,12 +92,17 @@ const renderWaterfallBar = (
|
|
|
92
92
|
barWidth: number,
|
|
93
93
|
barColor: string,
|
|
94
94
|
laneColor: string,
|
|
95
|
-
|
|
95
|
+
rowBg: string,
|
|
96
|
+
): { readonly segments: readonly WaterfallBarSegment[] } => {
|
|
97
|
+
// Timeline semantics: the leading gap (before the bar starts) is the
|
|
98
|
+
// "runway" showing how long after trace start this span kicked in — render
|
|
99
|
+
// it in the lane color. The trailing gap (after the bar ends) is post-span
|
|
100
|
+
// dead time — render it in the row bg so it visually disappears.
|
|
96
101
|
if (barWidth < 3 || trace.durationMs === 0) {
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
}
|
|
102
|
+
const trailing = Math.max(0, barWidth - 1)
|
|
103
|
+
const segs: WaterfallBarSegment[] = [{ text: "\u2588", fg: barColor }]
|
|
104
|
+
if (trailing > 0) segs.push({ text: " ".repeat(trailing), fg: rowBg, bg: rowBg })
|
|
105
|
+
return { segments: segs }
|
|
101
106
|
}
|
|
102
107
|
|
|
103
108
|
const traceStart = trace.startedAt.getTime()
|
|
@@ -114,33 +119,40 @@ const renderWaterfallBar = (
|
|
|
114
119
|
const endOffset = endUnits % 8
|
|
115
120
|
const segments: WaterfallBarSegment[] = []
|
|
116
121
|
|
|
117
|
-
|
|
118
|
-
segments.push({ text: " ".repeat(
|
|
122
|
+
const pushLeading = (cells: number) => {
|
|
123
|
+
if (cells > 0) segments.push({ text: " ".repeat(cells), fg: laneColor, bg: laneColor })
|
|
124
|
+
}
|
|
125
|
+
const pushTrailing = (cells: number) => {
|
|
126
|
+
if (cells > 0) segments.push({ text: " ".repeat(cells), fg: rowBg, bg: rowBg })
|
|
119
127
|
}
|
|
120
128
|
|
|
129
|
+
pushLeading(startCell)
|
|
130
|
+
|
|
121
131
|
if (startCell === endCell) {
|
|
122
132
|
const singleCellUnits = Math.max(1, endUnits - startUnits)
|
|
123
133
|
if (singleCellUnits <= 4) {
|
|
124
134
|
const centeredMarker = ULTRA_SHORT_MARKERS[Math.max(0, singleCellUnits - 1)] ?? "\u258f"
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
135
|
+
// The marker is a left-aligned sliver — the rest of the cell is
|
|
136
|
+
// post-bar space, so it uses the row bg (transparent) rather than
|
|
137
|
+
// carrying the dark lane track past where the span ended.
|
|
138
|
+
segments.push({ text: centeredMarker, fg: barColor, bg: rowBg })
|
|
139
|
+
pushTrailing(Math.max(0, barWidth - startCell - 1))
|
|
140
|
+
return { segments }
|
|
130
141
|
}
|
|
131
142
|
|
|
132
143
|
if (startOffset === 0) {
|
|
133
|
-
|
|
144
|
+
// Bar fills from the left of the cell; post-bar pixels fall to row bg.
|
|
145
|
+
segments.push({ text: PARTIAL_BLOCKS[singleCellUnits], fg: barColor, bg: rowBg })
|
|
134
146
|
} else {
|
|
147
|
+
// Bar starts partway into the cell; left pixels are lane, right is bar.
|
|
135
148
|
segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
|
|
136
149
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
afterCells: Math.max(0, barWidth - startCell - 1),
|
|
140
|
-
}
|
|
150
|
+
pushTrailing(Math.max(0, barWidth - startCell - 1))
|
|
151
|
+
return { segments }
|
|
141
152
|
}
|
|
142
153
|
|
|
143
154
|
if (startOffset > 0) {
|
|
155
|
+
// Leading partial: left portion is lane (runway), right is bar.
|
|
144
156
|
segments.push({ text: PARTIAL_BLOCKS[startOffset], fg: laneColor, bg: barColor })
|
|
145
157
|
}
|
|
146
158
|
|
|
@@ -152,13 +164,12 @@ const renderWaterfallBar = (
|
|
|
152
164
|
}
|
|
153
165
|
|
|
154
166
|
if (endOffset > 0) {
|
|
155
|
-
|
|
167
|
+
// Trailing partial: left portion is bar, right is row bg (transparent).
|
|
168
|
+
segments.push({ text: PARTIAL_BLOCKS[endOffset], fg: barColor, bg: rowBg })
|
|
156
169
|
}
|
|
157
170
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
afterCells: Math.max(0, barWidth - endCell - 1),
|
|
161
|
-
}
|
|
171
|
+
pushTrailing(Math.max(0, barWidth - endCell - 1))
|
|
172
|
+
return { segments }
|
|
162
173
|
}
|
|
163
174
|
|
|
164
175
|
const durationColor = (durationMs: number) => {
|
|
@@ -169,20 +180,42 @@ const durationColor = (durationMs: number) => {
|
|
|
169
180
|
return colors.muted
|
|
170
181
|
}
|
|
171
182
|
|
|
172
|
-
export const getWaterfallLayout = (contentWidth: number,
|
|
183
|
+
export const getWaterfallLayout = (contentWidth: number, suffixWidth: number) => {
|
|
173
184
|
const labelMaxWidth = Math.min(Math.floor(contentWidth * 0.4), 32)
|
|
174
|
-
|
|
175
|
-
const
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
178
193
|
}
|
|
179
194
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
186
219
|
}
|
|
187
220
|
|
|
188
221
|
export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[], maxEntries: number): Array<{ key: string; value: string; isWarning?: boolean }> => {
|
|
@@ -210,7 +243,6 @@ export const spanPreviewEntries = (span: TraceSpanItem, logs: readonly LogItem[]
|
|
|
210
243
|
|
|
211
244
|
const WaterfallRow = memo(({
|
|
212
245
|
span,
|
|
213
|
-
logCount,
|
|
214
246
|
trace,
|
|
215
247
|
index,
|
|
216
248
|
spans,
|
|
@@ -218,11 +250,10 @@ const WaterfallRow = memo(({
|
|
|
218
250
|
selected,
|
|
219
251
|
collapsed,
|
|
220
252
|
hasChildSpans,
|
|
253
|
+
suffixMetrics,
|
|
221
254
|
onSelect,
|
|
222
|
-
includeLogs,
|
|
223
255
|
}: {
|
|
224
256
|
span: TraceSpanItem
|
|
225
|
-
logCount: number
|
|
226
257
|
trace: TraceItem
|
|
227
258
|
index: number
|
|
228
259
|
spans: readonly TraceSpanItem[]
|
|
@@ -230,15 +261,15 @@ const WaterfallRow = memo(({
|
|
|
230
261
|
selected: boolean
|
|
231
262
|
collapsed: boolean
|
|
232
263
|
hasChildSpans: boolean
|
|
264
|
+
suffixMetrics: WaterfallSuffixMetrics
|
|
233
265
|
onSelect: () => void
|
|
234
|
-
includeLogs: boolean
|
|
235
266
|
}) => {
|
|
236
267
|
const prefix = buildTreePrefix(spans, index)
|
|
237
268
|
// Match the trace list indicator: `!` on error, chevron on collapsible parents, `·` on leaves.
|
|
238
269
|
const indicator = span.status === "error" ? "!" : hasChildSpans ? (collapsed ? "\u25b8" : "\u25be") : "\u00b7"
|
|
239
270
|
const opName = span.isRunning ? `${span.operationName} [${lifecycleLabel(span)}]` : span.operationName
|
|
240
271
|
|
|
241
|
-
const { labelMaxWidth, barWidth
|
|
272
|
+
const { labelMaxWidth, barWidth } = getWaterfallLayout(contentWidth, suffixMetrics.suffixWidth)
|
|
242
273
|
|
|
243
274
|
const opMaxWidth = Math.max(4, labelMaxWidth - prefix.length - 2)
|
|
244
275
|
const opTruncated = opName.length > opMaxWidth ? `${opName.slice(0, opMaxWidth - 1)}\u2026` : opName
|
|
@@ -248,14 +279,20 @@ const WaterfallRow = memo(({
|
|
|
248
279
|
const isError = span.status === "error"
|
|
249
280
|
const barColor = selected ? (isError ? waterfallColors.barSelectedError : waterfallColors.barSelected) : isError ? waterfallColors.barError : waterfallColors.bar
|
|
250
281
|
const laneColor = selected ? waterfallColors.barLane : waterfallColors.barBg
|
|
251
|
-
const
|
|
282
|
+
const rowBg = selected ? colors.selectedBg : colors.screenBg
|
|
283
|
+
const { segments } = renderWaterfallBar(span, trace, barWidth, barColor, laneColor, rowBg)
|
|
252
284
|
const bg = selected ? colors.selectedBg : undefined
|
|
253
285
|
const treeColor = selected ? colors.separator : colors.treeLine
|
|
254
286
|
const indicatorColor = isError ? colors.error : hasChildSpans ? (selected ? colors.selectedText : colors.muted) : colors.passing
|
|
255
287
|
const opColor = selected ? colors.selectedText : span.isRunning ? colors.warning : colors.text
|
|
256
288
|
|
|
257
289
|
const durationFg = durationColor(span.durationMs)
|
|
258
|
-
const
|
|
290
|
+
const unitFg = colors.muted
|
|
291
|
+
|
|
292
|
+
// Split the duration so the unit (s/ms) renders dimmer than the number.
|
|
293
|
+
const { number: durNumber, unit: durUnit } = splitDuration(Math.max(0, span.durationMs))
|
|
294
|
+
const durationCell = `${durNumber}${durUnit}`
|
|
295
|
+
const durationPad = " ".repeat(Math.max(0, suffixMetrics.maxDurationWidth - durationCell.length))
|
|
259
296
|
|
|
260
297
|
return (
|
|
261
298
|
<box height={1} onMouseDown={onSelect}>
|
|
@@ -268,10 +305,10 @@ const WaterfallRow = memo(({
|
|
|
268
305
|
{segments.map((segment, index) => (
|
|
269
306
|
<span key={`${span.spanId}-bar-${index}`} fg={segment.fg} bg={segment.bg}>{segment.text}</span>
|
|
270
307
|
))}
|
|
271
|
-
{afterCells > 0 ? <span fg={laneColor} bg={laneColor}>{" ".repeat(afterCells)}</span> : null}
|
|
272
308
|
<span> </span>
|
|
273
|
-
<span
|
|
274
|
-
|
|
309
|
+
<span>{durationPad}</span>
|
|
310
|
+
<span fg={durationFg}>{durNumber}</span>
|
|
311
|
+
<span fg={unitFg}>{durUnit}</span>
|
|
275
312
|
</TextLine>
|
|
276
313
|
</box>
|
|
277
314
|
)
|
|
@@ -349,7 +386,6 @@ export const WaterfallTimeline = ({
|
|
|
349
386
|
onSelectSpan: (index: number) => void
|
|
350
387
|
}) => {
|
|
351
388
|
const selectedSpan = selectedSpanIndex !== null ? filteredSpans[selectedSpanIndex] ?? null : null
|
|
352
|
-
const includeLogs = filteredSpans.some((span) => (spanLogCounts.get(span.spanId) ?? 0) > 0)
|
|
353
389
|
|
|
354
390
|
const spanIndexById = new Map<string, number>()
|
|
355
391
|
for (let i = 0; i < trace.spans.length; i++) {
|
|
@@ -382,8 +418,28 @@ export const WaterfallTimeline = ({
|
|
|
382
418
|
const windowSpans = filteredSpans.slice(windowStart, windowStart + viewportSize)
|
|
383
419
|
const blankCount = Math.max(0, viewportSize - windowSpans.length)
|
|
384
420
|
|
|
421
|
+
// One shared suffix width, measured from the current viewport, so every
|
|
422
|
+
// row's duration cell lines up on the same right-edge column.
|
|
423
|
+
const suffixMetrics = getWaterfallSuffixMetrics(windowSpans)
|
|
424
|
+
|
|
425
|
+
// Mouse wheel moves the span selection by the scroll delta. The waterfall
|
|
426
|
+
// uses virtual windowing (not a scrollbox) so native scroll does nothing;
|
|
427
|
+
// we convert wheel events into selection moves, which the windowing code
|
|
428
|
+
// already translates into visible-viewport shifts.
|
|
429
|
+
const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
|
|
430
|
+
const info = event.scroll
|
|
431
|
+
if (!info || filteredSpans.length === 0) return
|
|
432
|
+
const magnitude = Math.max(1, Math.round(info.delta))
|
|
433
|
+
const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
|
|
434
|
+
if (signed === 0) return
|
|
435
|
+
const start = selectedSpanIndex ?? 0
|
|
436
|
+
const next = Math.max(0, Math.min(start + signed, filteredSpans.length - 1))
|
|
437
|
+
if (next !== selectedSpanIndex) onSelectSpan(next)
|
|
438
|
+
event.stopPropagation?.()
|
|
439
|
+
}
|
|
440
|
+
|
|
385
441
|
return (
|
|
386
|
-
<box flexDirection="column">
|
|
442
|
+
<box flexDirection="column" onMouseScroll={handleWheel}>
|
|
387
443
|
{windowSpans.map((span, index) => {
|
|
388
444
|
const actualIndex = windowStart + index
|
|
389
445
|
const fullIndex = spanIndexById.get(span.spanId) ?? -1
|
|
@@ -391,7 +447,6 @@ export const WaterfallTimeline = ({
|
|
|
391
447
|
<WaterfallRow
|
|
392
448
|
key={`${trace.traceId}-${span.spanId}`}
|
|
393
449
|
span={span}
|
|
394
|
-
logCount={spanLogCounts.get(span.spanId) ?? 0}
|
|
395
450
|
trace={trace}
|
|
396
451
|
index={fullIndex}
|
|
397
452
|
spans={trace.spans}
|
|
@@ -399,7 +454,7 @@ export const WaterfallTimeline = ({
|
|
|
399
454
|
selected={selectedSpanIndex === actualIndex}
|
|
400
455
|
collapsed={collapsedSpanIds.has(span.spanId)}
|
|
401
456
|
hasChildSpans={fullIndex >= 0 && findFirstChildIndex(trace.spans, fullIndex) !== null}
|
|
402
|
-
|
|
457
|
+
suffixMetrics={suffixMetrics}
|
|
403
458
|
onSelect={() => onSelectSpan(actualIndex)}
|
|
404
459
|
/>
|
|
405
460
|
)
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
-
import type { RefObject } from "react"
|
|
3
1
|
import { FilterBar } from "../primitives.tsx"
|
|
4
|
-
import {
|
|
2
|
+
import { TraceListBody, TraceListHeader, type TraceListProps } from "../TraceList.tsx"
|
|
5
3
|
|
|
6
4
|
interface TraceListPaneProps {
|
|
7
5
|
readonly traceListProps: TraceListProps
|
|
@@ -11,9 +9,16 @@ interface TraceListPaneProps {
|
|
|
11
9
|
readonly containerHeight: number
|
|
12
10
|
readonly bodyHeight: number
|
|
13
11
|
readonly padding: number
|
|
14
|
-
readonly scrollRef: RefObject<ScrollBoxRenderable | null>
|
|
15
12
|
}
|
|
16
13
|
|
|
14
|
+
/**
|
|
15
|
+
* Replaced the opentui <scrollbox> with a direct virtual-windowed body.
|
|
16
|
+
* Rationale: the scrollbox's scrollSize is updated during opentui's render
|
|
17
|
+
* pass, not during React commit, so the useLayoutEffect that adjusted
|
|
18
|
+
* scrollTop on refresh was reading a stale max and clamping our intended
|
|
19
|
+
* scroll position. Rendering only the visible rows ourselves keeps the
|
|
20
|
+
* viewport math entirely in React state and eliminates the race.
|
|
21
|
+
*/
|
|
17
22
|
export const TraceListPane = ({
|
|
18
23
|
traceListProps,
|
|
19
24
|
filterMode,
|
|
@@ -22,13 +27,13 @@ export const TraceListPane = ({
|
|
|
22
27
|
containerHeight,
|
|
23
28
|
bodyHeight,
|
|
24
29
|
padding,
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
<
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
<
|
|
32
|
-
</
|
|
33
|
-
|
|
34
|
-
|
|
30
|
+
}: TraceListPaneProps) => {
|
|
31
|
+
const bodyRows = Math.max(1, filterMode ? bodyHeight - 1 : bodyHeight)
|
|
32
|
+
return (
|
|
33
|
+
<box height={containerHeight} flexDirection="column" paddingLeft={padding} paddingRight={0}>
|
|
34
|
+
<TraceListHeader {...traceListProps} />
|
|
35
|
+
{filterMode ? <FilterBar text={filterText} width={filterWidth} /> : null}
|
|
36
|
+
<TraceListBody {...traceListProps} viewportRows={bodyRows} />
|
|
37
|
+
</box>
|
|
38
|
+
)
|
|
39
|
+
}
|
|
@@ -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"],
|