@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
|
@@ -94,6 +94,7 @@ interface FacetSearch {
|
|
|
94
94
|
readonly type: "traces" | "logs"
|
|
95
95
|
readonly field: string
|
|
96
96
|
readonly serviceName?: string | null
|
|
97
|
+
readonly key?: string | null
|
|
97
98
|
readonly lookbackMinutes?: number
|
|
98
99
|
readonly limit?: number
|
|
99
100
|
}
|
|
@@ -612,41 +613,73 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
612
613
|
const now = yield* Clock.currentTimeMillis
|
|
613
614
|
|
|
614
615
|
yield* Effect.sync(() => {
|
|
615
|
-
let deletedData = false
|
|
616
|
-
// Time-based retention
|
|
617
616
|
const cutoff = now - config.otel.retentionHours * 60 * 60 * 1000
|
|
618
|
-
const deletedSpans = db.query(`DELETE FROM spans WHERE start_time_ms < ?`).run(cutoff) as { changes?: number }
|
|
619
|
-
const deletedLogs = db.query(`DELETE FROM logs WHERE timestamp_ms < ?`).run(cutoff) as { changes?: number }
|
|
620
|
-
deletedData = (deletedSpans.changes ?? 0) > 0 || (deletedLogs.changes ?? 0) > 0
|
|
621
617
|
|
|
622
|
-
//
|
|
623
|
-
//
|
|
624
|
-
//
|
|
618
|
+
// Evict at TRACE granularity so we never leave a trace half-gutted
|
|
619
|
+
// (previous logic deleted oldest 20% of spans, which happily sliced
|
|
620
|
+
// across traces and corrupted the summary rebuild). Running traces
|
|
621
|
+
// are protected — only `active_span_count = 0` summaries are in
|
|
622
|
+
// scope for eviction.
|
|
623
|
+
const toEvict = new Set<string>()
|
|
624
|
+
|
|
625
|
+
// Time-based: completed traces whose last span ended before cutoff.
|
|
626
|
+
const timeExpired = db.query(
|
|
627
|
+
`SELECT trace_id FROM trace_summaries WHERE active_span_count = 0 AND ended_at_ms > 0 AND ended_at_ms < ?`,
|
|
628
|
+
).all(cutoff) as readonly { trace_id: string }[]
|
|
629
|
+
for (const row of timeExpired) toEvict.add(row.trace_id)
|
|
630
|
+
|
|
631
|
+
// Size-based: if actual data exceeds cap, drop oldest 20% of the
|
|
632
|
+
// remaining completed traces. `(page_count - freelist_count)`
|
|
633
|
+
// ignores freed-but-not-vacuumed pages so a large freelist doesn't
|
|
634
|
+
// trigger a deletion death spiral.
|
|
625
635
|
const pageCount = (db.query(`PRAGMA page_count`).get() as { page_count: number }).page_count
|
|
626
636
|
const freePages = (db.query(`PRAGMA freelist_count`).get() as { freelist_count: number }).freelist_count
|
|
627
637
|
const pageSize = (db.query(`PRAGMA page_size`).get() as { page_size: number }).page_size
|
|
628
638
|
const dbSize = (pageCount - freePages) * pageSize
|
|
629
639
|
if (dbSize > maxDbSizeBytes) {
|
|
630
|
-
const
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
const
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
640
|
+
const completedCount = (db.query(
|
|
641
|
+
`SELECT COUNT(*) AS c FROM trace_summaries WHERE active_span_count = 0`,
|
|
642
|
+
).get() as { c: number }).c
|
|
643
|
+
const traceCutCount = Math.max(1, Math.floor(completedCount * 0.2))
|
|
644
|
+
const oldest = db.query(
|
|
645
|
+
`SELECT trace_id FROM trace_summaries WHERE active_span_count = 0 ORDER BY started_at_ms ASC LIMIT ?`,
|
|
646
|
+
).all(traceCutCount) as readonly { trace_id: string }[]
|
|
647
|
+
// Set.add dedupes overlap with the time-expired batch above.
|
|
648
|
+
for (const row of oldest) toEvict.add(row.trace_id)
|
|
637
649
|
}
|
|
638
650
|
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
651
|
+
// Always prune orphan logs (no trace_id) by timestamp — they're
|
|
652
|
+
// not covered by trace eviction.
|
|
653
|
+
db.query(`DELETE FROM logs WHERE trace_id IS NULL AND timestamp_ms < ?`).run(cutoff)
|
|
654
|
+
|
|
655
|
+
if (toEvict.size === 0) return
|
|
656
|
+
|
|
657
|
+
// Batch the trace-id list so the IN placeholders stay under
|
|
658
|
+
// SQLite's default limit (~999). Each batch wipes every row
|
|
659
|
+
// reachable from those trace_ids across the cascade tables.
|
|
660
|
+
const traceIds = Array.from(toEvict)
|
|
661
|
+
const BATCH_SIZE = 500
|
|
662
|
+
for (let offset = 0; offset < traceIds.length; offset += BATCH_SIZE) {
|
|
663
|
+
const batch = traceIds.slice(offset, offset + BATCH_SIZE)
|
|
664
|
+
const placeholders = batch.map(() => "?").join(",")
|
|
665
|
+
db.query(`DELETE FROM span_attributes WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
642
666
|
try {
|
|
643
|
-
db.query(`DELETE FROM span_operation_fts WHERE
|
|
644
|
-
db.query(`DELETE FROM log_body_fts WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = CAST(log_body_fts.log_id AS INTEGER))`).run()
|
|
667
|
+
db.query(`DELETE FROM span_operation_fts WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
645
668
|
} catch {
|
|
646
|
-
// FTS
|
|
669
|
+
// FTS table may not exist on old DBs.
|
|
647
670
|
}
|
|
648
|
-
db.query(`DELETE FROM
|
|
649
|
-
|
|
671
|
+
db.query(`DELETE FROM spans WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
672
|
+
db.query(`DELETE FROM logs WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
673
|
+
db.query(`DELETE FROM trace_summaries WHERE trace_id IN (${placeholders})`).run(...batch)
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// Log-side orphans (log_attributes + FTS) are keyed by log.id,
|
|
677
|
+
// so prune what no longer has a parent log row.
|
|
678
|
+
db.query(`DELETE FROM log_attributes WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = log_attributes.log_id)`).run()
|
|
679
|
+
try {
|
|
680
|
+
db.query(`DELETE FROM log_body_fts WHERE NOT EXISTS (SELECT 1 FROM logs WHERE logs.id = CAST(log_body_fts.log_id AS INTEGER))`).run()
|
|
681
|
+
} catch {
|
|
682
|
+
// FTS table may not exist on old DBs.
|
|
650
683
|
}
|
|
651
684
|
})
|
|
652
685
|
})
|
|
@@ -1424,6 +1457,49 @@ export const TelemetryStoreLive = Layer.effect(
|
|
|
1424
1457
|
`).all(...(input.serviceName ? [cutoff, input.serviceName, limit] : [cutoff, limit])) as Array<{ value: string; count: number }>
|
|
1425
1458
|
return rows
|
|
1426
1459
|
}
|
|
1460
|
+
if (input.field === "attribute_keys") {
|
|
1461
|
+
// Count distinct traces each attribute key appears on, optionally
|
|
1462
|
+
// scoped to a service. Keys with many distinct values (e.g. sessionId,
|
|
1463
|
+
// user id, model) rank higher than keys that are constant across every
|
|
1464
|
+
// trace (service.name, telemetry.sdk.*) — the latter can't discriminate
|
|
1465
|
+
// between traces so they're useless as filters.
|
|
1466
|
+
const params: Array<string | number> = [cutoff]
|
|
1467
|
+
if (input.serviceName) params.push(input.serviceName)
|
|
1468
|
+
params.push(limit)
|
|
1469
|
+
const rows = db.query(`
|
|
1470
|
+
SELECT sa.key AS value,
|
|
1471
|
+
COUNT(DISTINCT sa.trace_id) AS count,
|
|
1472
|
+
COUNT(DISTINCT sa.value) AS distinct_values
|
|
1473
|
+
FROM span_attributes sa
|
|
1474
|
+
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1475
|
+
WHERE s.start_time_ms >= ?
|
|
1476
|
+
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1477
|
+
GROUP BY sa.key
|
|
1478
|
+
ORDER BY (CASE WHEN distinct_values = 1 THEN 1 ELSE 0 END) ASC,
|
|
1479
|
+
distinct_values DESC,
|
|
1480
|
+
count DESC,
|
|
1481
|
+
value ASC
|
|
1482
|
+
LIMIT ?
|
|
1483
|
+
`).all(...params) as Array<{ value: string; count: number; distinct_values: number }>
|
|
1484
|
+
return rows.map((row) => ({ value: row.value, count: row.count }))
|
|
1485
|
+
}
|
|
1486
|
+
if (input.field === "attribute_values") {
|
|
1487
|
+
if (!input.key) return [] as FacetItem[]
|
|
1488
|
+
const params: Array<string | number> = [input.key, cutoff]
|
|
1489
|
+
if (input.serviceName) params.push(input.serviceName)
|
|
1490
|
+
params.push(limit)
|
|
1491
|
+
const rows = db.query(`
|
|
1492
|
+
SELECT sa.value AS value, COUNT(DISTINCT sa.trace_id) AS count
|
|
1493
|
+
FROM span_attributes sa
|
|
1494
|
+
JOIN spans s ON s.trace_id = sa.trace_id AND s.span_id = sa.span_id
|
|
1495
|
+
WHERE sa.key = ? AND s.start_time_ms >= ?
|
|
1496
|
+
${input.serviceName ? "AND s.service_name = ?" : ""}
|
|
1497
|
+
GROUP BY sa.value
|
|
1498
|
+
ORDER BY count DESC, value ASC
|
|
1499
|
+
LIMIT ?
|
|
1500
|
+
`).all(...params) as Array<{ value: string; count: number }>
|
|
1501
|
+
return rows
|
|
1502
|
+
}
|
|
1427
1503
|
}
|
|
1428
1504
|
|
|
1429
1505
|
return [] as FacetItem[]
|
|
@@ -8,6 +8,8 @@ export class TraceQueryService extends Context.Service<
|
|
|
8
8
|
readonly listServices: Effect.Effect<readonly string[], Error>
|
|
9
9
|
readonly listRecentTraces: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceItem[], Error>
|
|
10
10
|
readonly listTraceSummaries: (serviceName: string, options?: { readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
11
|
+
readonly searchTraceSummaries: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceSummaryItem[], Error>
|
|
12
|
+
readonly listFacets: (input: { readonly type: "traces" | "logs"; readonly field: string; readonly serviceName?: string | null; readonly key?: string | null; readonly lookbackMinutes?: number; readonly limit?: number }) => Effect.Effect<readonly { readonly value: string; readonly count: number }[], Error>
|
|
11
13
|
readonly searchTraces: (input: { readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly TraceItem[], Error>
|
|
12
14
|
readonly traceStats: (input: { readonly groupBy: string; readonly agg: "count" | "avg_duration" | "p95_duration" | "error_rate"; readonly serviceName?: string | null; readonly operation?: string | null; readonly status?: "ok" | "error" | null; readonly minDurationMs?: number | null; readonly lookbackMinutes?: number; readonly limit?: number; readonly attributeFilters?: Readonly<Record<string, string>> }) => Effect.Effect<readonly { readonly group: string; readonly value: number; readonly count: number }[], Error>
|
|
13
15
|
readonly getTrace: (traceId: string) => Effect.Effect<TraceItem | null, Error>
|
|
@@ -60,6 +62,8 @@ export const TraceQueryServiceLive = Layer.effect(
|
|
|
60
62
|
listServices,
|
|
61
63
|
listRecentTraces,
|
|
62
64
|
listTraceSummaries,
|
|
65
|
+
searchTraceSummaries: store.searchTraceSummaries,
|
|
66
|
+
listFacets: store.listFacets,
|
|
63
67
|
searchTraces: store.searchTraces,
|
|
64
68
|
traceStats: store.traceStats,
|
|
65
69
|
getTrace,
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { BlankRow, TextLine } from "./primitives.tsx"
|
|
3
|
+
import { colors } from "./theme.ts"
|
|
4
|
+
import { fitCell, truncateText } from "./format.ts"
|
|
5
|
+
import type { AttrFacetState, AttrPickerMode } from "./state.ts"
|
|
6
|
+
|
|
7
|
+
export interface AttrFilterModalProps {
|
|
8
|
+
readonly width: number
|
|
9
|
+
readonly height: number
|
|
10
|
+
readonly mode: Exclude<AttrPickerMode, "off">
|
|
11
|
+
readonly input: string
|
|
12
|
+
readonly selectedIndex: number
|
|
13
|
+
readonly selectedKey: string | null
|
|
14
|
+
readonly state: AttrFacetState
|
|
15
|
+
readonly onClose: () => void
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Filter + rank facet rows by the user's current input so typing is
|
|
19
|
+
// responsive even on large key sets. Values list skips this — attribute
|
|
20
|
+
// values are usually opaque ids that users paste in whole.
|
|
21
|
+
export const filterFacets = (
|
|
22
|
+
rows: readonly { readonly value: string; readonly count: number }[],
|
|
23
|
+
input: string,
|
|
24
|
+
): readonly { readonly value: string; readonly count: number }[] => {
|
|
25
|
+
const needle = input.trim().toLowerCase()
|
|
26
|
+
if (!needle) return rows
|
|
27
|
+
return rows.filter((row) => row.value.toLowerCase().includes(needle))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const AttrFilterModal = ({
|
|
31
|
+
width,
|
|
32
|
+
height,
|
|
33
|
+
mode,
|
|
34
|
+
input,
|
|
35
|
+
selectedIndex,
|
|
36
|
+
selectedKey,
|
|
37
|
+
state,
|
|
38
|
+
onClose,
|
|
39
|
+
}: AttrFilterModalProps) => {
|
|
40
|
+
const panelWidth = Math.min(92, Math.max(60, width - 10))
|
|
41
|
+
const left = Math.max(2, Math.floor((width - panelWidth) / 2))
|
|
42
|
+
const top = Math.max(1, Math.floor(height / 6))
|
|
43
|
+
const innerWidth = panelWidth - 4
|
|
44
|
+
const rows = filterFacets(state.data, input)
|
|
45
|
+
const clampedIndex = rows.length === 0 ? 0 : Math.max(0, Math.min(selectedIndex, rows.length - 1))
|
|
46
|
+
const visibleRowCount = Math.max(5, Math.min(18, height - top - 8))
|
|
47
|
+
const windowStart = Math.max(0, clampedIndex - Math.floor(visibleRowCount / 2))
|
|
48
|
+
const windowEnd = Math.min(rows.length, windowStart + visibleRowCount)
|
|
49
|
+
const windowed = rows.slice(windowStart, windowEnd)
|
|
50
|
+
|
|
51
|
+
const title = mode === "keys"
|
|
52
|
+
? "Filter traces by attribute key"
|
|
53
|
+
: `Filter · ${truncateText(selectedKey ?? "", innerWidth - 14)}`
|
|
54
|
+
|
|
55
|
+
const hint = mode === "keys"
|
|
56
|
+
? "type to narrow · ↑↓ move · enter select · esc cancel"
|
|
57
|
+
: "type to narrow · ↑↓ move · enter apply · backspace keys · esc cancel"
|
|
58
|
+
|
|
59
|
+
const countWidth = 7
|
|
60
|
+
const valueWidth = Math.max(10, innerWidth - countWidth - 1)
|
|
61
|
+
|
|
62
|
+
const renderRow = (row: { readonly value: string; readonly count: number }, isSelected: boolean) => {
|
|
63
|
+
const label = fitCell(row.value, valueWidth)
|
|
64
|
+
const count = String(row.count).padStart(countWidth - 1) + " "
|
|
65
|
+
if (isSelected) {
|
|
66
|
+
return (
|
|
67
|
+
<TextLine fg={colors.text} bg={colors.selectedBg}>
|
|
68
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{label}</span>
|
|
69
|
+
<span fg={colors.muted}>{" "}</span>
|
|
70
|
+
<span fg={colors.muted}>{count}</span>
|
|
71
|
+
</TextLine>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
return (
|
|
75
|
+
<TextLine>
|
|
76
|
+
<span fg={colors.text}>{label}</span>
|
|
77
|
+
<span fg={colors.muted}>{" "}</span>
|
|
78
|
+
<span fg={colors.count}>{count}</span>
|
|
79
|
+
</TextLine>
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<box position="absolute" zIndex={3000} left={0} top={0} width={width} height={height} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
|
|
85
|
+
<box position="absolute" left={left} top={top} width={panelWidth} flexDirection="column" backgroundColor={RGBA.fromInts(20, 20, 28, 255)}>
|
|
86
|
+
<box paddingLeft={2} paddingRight={2} paddingTop={1} paddingBottom={1} flexDirection="column">
|
|
87
|
+
<TextLine>
|
|
88
|
+
<span fg={colors.count} attributes={TextAttributes.BOLD}>{truncateText(title, innerWidth)}</span>
|
|
89
|
+
</TextLine>
|
|
90
|
+
<TextLine>
|
|
91
|
+
<span fg={colors.muted}>{truncateText(hint, innerWidth)}</span>
|
|
92
|
+
</TextLine>
|
|
93
|
+
<BlankRow />
|
|
94
|
+
<TextLine fg={colors.accent}>
|
|
95
|
+
<span fg={colors.muted}>{"\u203a "}</span>
|
|
96
|
+
<span fg={colors.text}>{truncateText(input, Math.max(1, innerWidth - 4))}</span>
|
|
97
|
+
<span fg={colors.accent}>{"\u2588"}</span>
|
|
98
|
+
</TextLine>
|
|
99
|
+
<BlankRow />
|
|
100
|
+
{state.status === "loading" && rows.length === 0 ? (
|
|
101
|
+
<TextLine><span fg={colors.muted}>loading…</span></TextLine>
|
|
102
|
+
) : state.error ? (
|
|
103
|
+
<TextLine><span fg={colors.error}>{truncateText(state.error, innerWidth)}</span></TextLine>
|
|
104
|
+
) : rows.length === 0 ? (
|
|
105
|
+
<TextLine><span fg={colors.muted}>no matches</span></TextLine>
|
|
106
|
+
) : (
|
|
107
|
+
windowed.map((row, i) => (
|
|
108
|
+
<box key={row.value} height={1}>
|
|
109
|
+
{renderRow(row, windowStart + i === clampedIndex)}
|
|
110
|
+
</box>
|
|
111
|
+
))
|
|
112
|
+
)}
|
|
113
|
+
{rows.length > windowEnd ? (
|
|
114
|
+
<TextLine><span fg={colors.muted}>{`+${rows.length - windowEnd} more…`}</span></TextLine>
|
|
115
|
+
) : null}
|
|
116
|
+
</box>
|
|
117
|
+
</box>
|
|
118
|
+
</box>
|
|
119
|
+
)
|
|
120
|
+
}
|
|
@@ -30,8 +30,7 @@ export const SpanDetailPane = ({
|
|
|
30
30
|
paneWidth: number
|
|
31
31
|
focused?: boolean
|
|
32
32
|
}) => {
|
|
33
|
-
const
|
|
34
|
-
const headerTitle = `${focusIndicator}SPAN`
|
|
33
|
+
const headerTitle = "SPAN"
|
|
35
34
|
const headerRight = span
|
|
36
35
|
? `${span.status} \u00b7 ${formatDuration(span.durationMs)}${logs.length > 0 ? ` \u00b7 ${logs.length} lg` : ""}`
|
|
37
36
|
: "no span selected"
|
|
@@ -67,8 +67,7 @@ export const TraceDetailsPane = ({
|
|
|
67
67
|
const hasTraceSelection = traceSummary !== null
|
|
68
68
|
const isLoadingTrace = hasTraceSelection && trace === null && traceStatus !== "error"
|
|
69
69
|
|
|
70
|
-
const
|
|
71
|
-
const headerTitle = `${focusIndicator}TRACE DETAILS`
|
|
70
|
+
const headerTitle = "TRACE DETAILS"
|
|
72
71
|
const headerRight = traceMeta
|
|
73
72
|
? `${traceMeta.errorCount > 0 ? `${traceMeta.errorCount} errors` : traceMeta.isRunning ? "running" : isLoadingTrace ? "loading" : "healthy"} \u00b7 ${formatDuration(traceMeta.durationMs)}${traceLogCount > 0 ? ` \u00b7 ${traceLogCount} logs` : ""}`
|
|
74
73
|
: traceStatus === "error"
|
|
@@ -86,7 +85,6 @@ export const TraceDetailsPane = ({
|
|
|
86
85
|
const opLeft = traceMeta?.rootOperationName ?? ""
|
|
87
86
|
const opGap = Math.max(2, contentWidth - opLeft.length - dateStr.length)
|
|
88
87
|
const warningCount = traceMeta?.warnings.length ?? 0
|
|
89
|
-
const firstWarning = traceMeta?.warnings[0] ?? ""
|
|
90
88
|
|
|
91
89
|
return (
|
|
92
90
|
<box flexDirection="column" width={paneWidth} height={bodyLines + TRACE_DETAILS_HEADER_ROWS} overflow="hidden">
|
|
@@ -101,23 +99,17 @@ export const TraceDetailsPane = ({
|
|
|
101
99
|
<span>{" ".repeat(opGap)}</span>
|
|
102
100
|
<span fg={colors.muted}>{dateStr}</span>
|
|
103
101
|
</TextLine>
|
|
104
|
-
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
<
|
|
114
|
-
|
|
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
|
-
)}
|
|
102
|
+
<TextLine>
|
|
103
|
+
<span fg={colors.count}>{trace.spanCount} spans</span>
|
|
104
|
+
{warningCount > 0 ? (
|
|
105
|
+
<>
|
|
106
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
107
|
+
<span fg={colors.error}>{warningCount} warning{warningCount === 1 ? "" : "s"}</span>
|
|
108
|
+
</>
|
|
109
|
+
) : null}
|
|
110
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
111
|
+
<span fg={colors.muted}>{trace.traceId}</span>
|
|
112
|
+
</TextLine>
|
|
121
113
|
</box>
|
|
122
114
|
<Divider width={paneWidth} />
|
|
123
115
|
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
@@ -143,11 +135,11 @@ export const TraceDetailsPane = ({
|
|
|
143
135
|
<span fg={colors.muted}>{dateStr}</span>
|
|
144
136
|
</TextLine>
|
|
145
137
|
<TextLine>
|
|
146
|
-
<span fg={colors.defaultService}>{traceMeta.serviceName}</span>
|
|
147
|
-
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
148
138
|
<span fg={colors.count}>{traceMeta.spanCount} spans</span>
|
|
149
139
|
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
150
140
|
<span fg={colors.count}>warming adjacent trace...</span>
|
|
141
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
142
|
+
<span fg={colors.muted}>{traceMeta.traceId}</span>
|
|
151
143
|
</TextLine>
|
|
152
144
|
</box>
|
|
153
145
|
<Divider width={paneWidth} />
|
package/src/ui/TraceList.tsx
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { TextAttributes } from "@opentui/core"
|
|
2
|
-
import {
|
|
3
|
-
import { config } from "../config.ts"
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from "react"
|
|
4
3
|
import type { TraceSummaryItem } from "../domain.ts"
|
|
5
4
|
import { fitCell, formatDuration, lifecycleLabel, relativeTime, traceIndicator, traceIndicatorColor, traceRowId } from "./format.ts"
|
|
6
|
-
import { PlainLine, TextLine } from "./primitives.tsx"
|
|
5
|
+
import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
|
|
7
6
|
import type { LoadStatus } from "./state.ts"
|
|
8
7
|
import { colors } from "./theme.ts"
|
|
9
8
|
|
|
@@ -12,9 +11,11 @@ const getTraceRowLayout = (contentWidth: number) => {
|
|
|
12
11
|
const durationWidth = 8
|
|
13
12
|
const countWidth = 6
|
|
14
13
|
const ageWidth = 4
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
|
|
14
|
+
// Row layout: state + gap + title + duration + gap + count + gap + age.
|
|
15
|
+
// Let the title expand to fill whatever width is left so the metrics
|
|
16
|
+
// cluster lands against the right edge of the pane.
|
|
17
|
+
const fixed = stateWidth + durationWidth + countWidth + ageWidth + 3
|
|
18
|
+
const titleWidth = Math.max(8, contentWidth - fixed)
|
|
18
19
|
return { stateWidth, durationWidth, countWidth, ageWidth, titleWidth }
|
|
19
20
|
}
|
|
20
21
|
|
|
@@ -40,13 +41,17 @@ const TraceRow = ({
|
|
|
40
41
|
: trace.rootOperationName
|
|
41
42
|
const titleColor = selected ? colors.selectedText : trace.isRunning ? colors.warning : colors.text
|
|
42
43
|
|
|
44
|
+
// Always surface a duration, including `0ms` for sub-millisecond traces —
|
|
45
|
+
// a visible duration is easier to scan than a blank column.
|
|
46
|
+
const durationText = formatDuration(Math.max(0, trace.durationMs))
|
|
47
|
+
|
|
43
48
|
return (
|
|
44
49
|
<box id={traceRowId(trace.traceId)} height={1} onMouseDown={onSelect}>
|
|
45
50
|
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
46
51
|
<span fg={traceIndicatorColor(trace)}>{fitCell(traceIndicator(trace), stateWidth)}</span>
|
|
47
52
|
<span> </span>
|
|
48
53
|
<span fg={titleColor}>{fitTraceTitle(title, titleWidth)}</span>
|
|
49
|
-
<span fg={
|
|
54
|
+
<span fg={colors.muted}>{fitCell(durationText, durationWidth, "right")}</span>
|
|
50
55
|
<span> </span>
|
|
51
56
|
<span fg={colors.muted}>{fitCell(`${trace.spanCount}sp`, countWidth, "right")}</span>
|
|
52
57
|
<span> </span>
|
|
@@ -71,8 +76,53 @@ export interface TraceListProps {
|
|
|
71
76
|
readonly onSelectTrace: (traceId: string) => void
|
|
72
77
|
}
|
|
73
78
|
|
|
74
|
-
|
|
75
|
-
|
|
79
|
+
interface TraceListBodyProps extends TraceListProps {
|
|
80
|
+
readonly viewportRows: number
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Header strip that sits above the body (renders the `TRACES 100 · filter: x`
|
|
85
|
+
* line). Kept as a separate component so the body can live inside a
|
|
86
|
+
* virtual-windowed box without the header scrolling with it.
|
|
87
|
+
*/
|
|
88
|
+
export const TraceListHeader = ({
|
|
89
|
+
traces,
|
|
90
|
+
services,
|
|
91
|
+
selectedService,
|
|
92
|
+
filterText,
|
|
93
|
+
sortMode,
|
|
94
|
+
totalCount,
|
|
95
|
+
contentWidth,
|
|
96
|
+
}: TraceListProps) => {
|
|
97
|
+
const countLabel = totalCount !== undefined && totalCount !== traces.length ? `${traces.length}/${totalCount}` : traces.length > 0 ? String(traces.length) : ""
|
|
98
|
+
const metaLabel = [
|
|
99
|
+
filterText ? `filter: ${filterText}` : null,
|
|
100
|
+
sortMode && sortMode !== "recent" ? `sort: ${sortMode}` : null,
|
|
101
|
+
].filter((part): part is string => part !== null).join(" · ")
|
|
102
|
+
const serviceLabel = services.length > 1 && selectedService ? `${services.length} services` : ""
|
|
103
|
+
const leftLabel = `TRACES${countLabel ? ` ${countLabel}` : ""}${metaLabel ? ` · ${metaLabel}` : ""}`
|
|
104
|
+
const gap = Math.max(2, contentWidth - leftLabel.length - serviceLabel.length)
|
|
105
|
+
return (
|
|
106
|
+
<TextLine>
|
|
107
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>TRACES</span>
|
|
108
|
+
{countLabel ? <span fg={colors.muted}>{` ${countLabel}`}</span> : null}
|
|
109
|
+
{metaLabel ? <span fg={colors.muted}>{` · ${metaLabel}`}</span> : null}
|
|
110
|
+
<span fg={colors.muted}>{" ".repeat(gap)}</span>
|
|
111
|
+
<span fg={colors.muted}>{serviceLabel}</span>
|
|
112
|
+
</TextLine>
|
|
113
|
+
)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Virtual-windowed body for the trace list. Replaces the previous
|
|
118
|
+
* opentui <scrollbox> which had a race with opentui's render-time Yoga
|
|
119
|
+
* layout: useLayoutEffect fires BEFORE the scrollbar's scrollSize has
|
|
120
|
+
* been updated to reflect new content height, so setting scrollTop after
|
|
121
|
+
* a refresh got clamped against the stale max. We own the scroll offset
|
|
122
|
+
* directly as React state and render only the visible rows, eliminating
|
|
123
|
+
* the race entirely.
|
|
124
|
+
*/
|
|
125
|
+
export const TraceListBody = ({
|
|
76
126
|
traces,
|
|
77
127
|
selectedTraceId,
|
|
78
128
|
status,
|
|
@@ -80,41 +130,103 @@ export const TraceList = ({
|
|
|
80
130
|
contentWidth,
|
|
81
131
|
services,
|
|
82
132
|
selectedService,
|
|
83
|
-
|
|
84
|
-
filterText,
|
|
85
|
-
sortMode,
|
|
86
|
-
totalCount,
|
|
133
|
+
viewportRows,
|
|
87
134
|
onSelectTrace,
|
|
88
|
-
}:
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
135
|
+
}: TraceListBodyProps) => {
|
|
136
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
137
|
+
// Track (selectedTraceId, its index in `traces`) from the previous render
|
|
138
|
+
// so we can detect the refresh-shift case (same traceId, new index because
|
|
139
|
+
// rows were prepended/removed around it) and slide scrollOffset by the
|
|
140
|
+
// same delta — preserving the selected row's visual position instead of
|
|
141
|
+
// letting it jump every time auto-refresh pulls in new traces.
|
|
142
|
+
const lastSelectedIdRef = useRef<string | null>(null)
|
|
143
|
+
const lastSelectedIndexRef = useRef<number | null>(null)
|
|
144
|
+
const lastServiceRef = useRef<string | null>(null)
|
|
145
|
+
|
|
146
|
+
const viewport = Math.max(1, viewportRows)
|
|
147
|
+
const maxOffset = Math.max(0, traces.length - viewport)
|
|
148
|
+
|
|
149
|
+
useLayoutEffect(() => {
|
|
150
|
+
// Service change or initial mount: pin to top.
|
|
151
|
+
if (lastServiceRef.current !== selectedService) {
|
|
152
|
+
lastServiceRef.current = selectedService
|
|
153
|
+
lastSelectedIdRef.current = null
|
|
154
|
+
lastSelectedIndexRef.current = null
|
|
155
|
+
setScrollOffset(0)
|
|
156
|
+
return
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!selectedTraceId) {
|
|
160
|
+
lastSelectedIdRef.current = null
|
|
161
|
+
lastSelectedIndexRef.current = null
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const index = traces.findIndex((t) => t.traceId === selectedTraceId)
|
|
166
|
+
if (index < 0) {
|
|
167
|
+
lastSelectedIdRef.current = null
|
|
168
|
+
lastSelectedIndexRef.current = null
|
|
169
|
+
return
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const prevId = lastSelectedIdRef.current
|
|
173
|
+
const prevIndex = lastSelectedIndexRef.current
|
|
174
|
+
const isRefreshShift = prevId === selectedTraceId && prevIndex !== null && prevIndex !== index
|
|
175
|
+
|
|
176
|
+
setScrollOffset((current) => {
|
|
177
|
+
let next = current
|
|
178
|
+
if (isRefreshShift) {
|
|
179
|
+
// Same row, new position because rows shifted around it — slide
|
|
180
|
+
// the window by the same delta to keep the row in the same
|
|
181
|
+
// visible slot.
|
|
182
|
+
next = current + (index - prevIndex)
|
|
183
|
+
} else if (index < current) {
|
|
184
|
+
// Selection moved above the viewport (user pressed k/up or
|
|
185
|
+
// jumped via gg/home). Snap the top to the selection.
|
|
186
|
+
next = index
|
|
187
|
+
} else if (index >= current + viewport) {
|
|
188
|
+
// Selection moved below the viewport — snap the bottom to it.
|
|
189
|
+
next = index - viewport + 1
|
|
190
|
+
}
|
|
191
|
+
return Math.max(0, Math.min(next, maxOffset))
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
lastSelectedIdRef.current = selectedTraceId
|
|
195
|
+
lastSelectedIndexRef.current = index
|
|
196
|
+
}, [traces, selectedTraceId, selectedService, viewport, maxOffset])
|
|
197
|
+
|
|
198
|
+
// Mouse wheel moves the scroll window WITHOUT touching selection — lets
|
|
199
|
+
// the user browse ahead of / behind their selected trace freely.
|
|
200
|
+
const handleWheel = (event: { scroll?: { direction: string; delta: number }; stopPropagation?: () => void }) => {
|
|
201
|
+
const info = event.scroll
|
|
202
|
+
if (!info || traces.length === 0) return
|
|
203
|
+
const magnitude = Math.max(1, Math.round(info.delta))
|
|
204
|
+
const signed = info.direction === "up" ? -magnitude : info.direction === "down" ? magnitude : 0
|
|
205
|
+
if (signed === 0) return
|
|
206
|
+
setScrollOffset((current) => Math.max(0, Math.min(current + signed, maxOffset)))
|
|
207
|
+
event.stopPropagation?.()
|
|
109
208
|
}
|
|
110
209
|
|
|
210
|
+
if (status === "loading" && traces.length === 0) {
|
|
211
|
+
return <PlainLine text="Loading traces..." fg={colors.muted} />
|
|
212
|
+
}
|
|
213
|
+
if (status === "error") {
|
|
214
|
+
return <PlainLine text={error ?? "Could not load traces."} fg={colors.error} />
|
|
215
|
+
}
|
|
216
|
+
if (status === "ready" && services.length === 0) {
|
|
217
|
+
return <PlainLine text="No services reporting yet. Start your app and emit a span." fg={colors.muted} />
|
|
218
|
+
}
|
|
219
|
+
if (status === "ready" && selectedService && traces.length === 0) {
|
|
220
|
+
return <PlainLine text="No traces in the current lookback window." fg={colors.muted} />
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const windowStart = Math.max(0, Math.min(scrollOffset, maxOffset))
|
|
224
|
+
const windowTraces = traces.slice(windowStart, windowStart + viewport)
|
|
225
|
+
const blanks = Math.max(0, viewport - windowTraces.length)
|
|
226
|
+
|
|
111
227
|
return (
|
|
112
|
-
<box flexDirection="column">
|
|
113
|
-
{
|
|
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) => (
|
|
228
|
+
<box flexDirection="column" onMouseScroll={handleWheel}>
|
|
229
|
+
{windowTraces.map((trace) => (
|
|
118
230
|
<TraceRow
|
|
119
231
|
key={trace.traceId}
|
|
120
232
|
trace={trace}
|
|
@@ -123,6 +235,20 @@ export const TraceList = ({
|
|
|
123
235
|
onSelect={() => onSelectTrace(trace.traceId)}
|
|
124
236
|
/>
|
|
125
237
|
))}
|
|
238
|
+
{Array.from({ length: blanks }, (_, i) => (
|
|
239
|
+
<BlankRow key={`trace-blank-${i}`} />
|
|
240
|
+
))}
|
|
126
241
|
</box>
|
|
127
242
|
)
|
|
128
243
|
}
|
|
244
|
+
|
|
245
|
+
// Backwards-compatible single-entry wrapper (header + body) for callers
|
|
246
|
+
// that haven't been updated to the split layout yet.
|
|
247
|
+
export const TraceList = ({
|
|
248
|
+
showHeader,
|
|
249
|
+
viewportRows,
|
|
250
|
+
...props
|
|
251
|
+
}: { showHeader: boolean; viewportRows?: number } & TraceListProps) => {
|
|
252
|
+
if (showHeader) return <TraceListHeader {...props} />
|
|
253
|
+
return <TraceListBody {...props} viewportRows={viewportRows ?? 20} />
|
|
254
|
+
}
|