@kitlangton/motel 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +142 -0
- package/LICENSE +21 -0
- package/README.md +199 -0
- package/package.json +92 -0
- package/src/App.tsx +217 -0
- package/src/cli.ts +258 -0
- package/src/config.ts +39 -0
- package/src/daemon.test.ts +59 -0
- package/src/daemon.ts +398 -0
- package/src/domain.ts +233 -0
- package/src/httpApi.ts +384 -0
- package/src/index.tsx +18 -0
- package/src/instructions.ts +72 -0
- package/src/localServer.ts +699 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +260 -0
- package/src/motel.ts +86 -0
- package/src/motelClient.ts +201 -0
- package/src/otlp.ts +142 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +86 -0
- package/src/runtime.ts +38 -0
- package/src/server.ts +10 -0
- package/src/services/LogQueryService.ts +43 -0
- package/src/services/TelemetryStore.ts +1821 -0
- package/src/services/TraceQueryService.ts +71 -0
- package/src/telemetry.test.ts +726 -0
- package/src/ui/ServiceLogs.tsx +112 -0
- package/src/ui/SpanDetail.tsx +134 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +91 -0
- package/src/ui/TraceDetailsPane.tsx +169 -0
- package/src/ui/TraceList.tsx +128 -0
- package/src/ui/Waterfall.tsx +412 -0
- package/src/ui/app/TraceListPane.tsx +34 -0
- package/src/ui/app/TraceWorkspace.tsx +254 -0
- package/src/ui/app/useAppLayout.ts +79 -0
- package/src/ui/app/useTraceScreenData.ts +411 -0
- package/src/ui/format.ts +119 -0
- package/src/ui/primitives.tsx +170 -0
- package/src/ui/state.ts +137 -0
- package/src/ui/theme.ts +153 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +115 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +220 -0
- package/src/ui/useKeyboardNav.ts +532 -0
- package/src/ui/waterfallNav.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +263 -0
- package/src/ui/waterfallNav.test.ts +422 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-BEKIiisE.js +27 -0
- package/web/dist/assets/index-DzuHNBGV.css +2 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { ScrollBoxRenderable } from "@opentui/core"
|
|
2
|
+
import { useLayoutEffect, useRef } from "react"
|
|
3
|
+
import type { LogItem } from "../domain.ts"
|
|
4
|
+
import { fitCell, formatLogTimestamp, formatTimestamp, logHeadline, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
|
|
5
|
+
import { Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
6
|
+
import type { ServiceLogState } from "./state.ts"
|
|
7
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
8
|
+
|
|
9
|
+
export const ServiceLogsView = ({
|
|
10
|
+
serviceName,
|
|
11
|
+
logsState,
|
|
12
|
+
selectedIndex,
|
|
13
|
+
onSelectLog,
|
|
14
|
+
contentWidth,
|
|
15
|
+
bodyLines,
|
|
16
|
+
}: {
|
|
17
|
+
serviceName: string | null
|
|
18
|
+
logsState: ServiceLogState
|
|
19
|
+
selectedIndex: number
|
|
20
|
+
onSelectLog: (index: number) => void
|
|
21
|
+
contentWidth: number
|
|
22
|
+
bodyLines: number
|
|
23
|
+
}) => {
|
|
24
|
+
const timeWidth = 8
|
|
25
|
+
const levelWidth = 5
|
|
26
|
+
const traceWidth = 8
|
|
27
|
+
const messageWidth = Math.max(16, contentWidth - timeWidth - levelWidth - traceWidth - 3)
|
|
28
|
+
|
|
29
|
+
if (logsState.status === "loading" && logsState.data.length === 0) {
|
|
30
|
+
return <PlainLine text="Loading recent service logs..." fg={colors.count} />
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (logsState.status === "error") {
|
|
34
|
+
return <PlainLine text={logsState.error ?? "Could not load logs."} fg={colors.error} />
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (logsState.data.length === 0) {
|
|
38
|
+
return <PlainLine text={`No logs captured yet for service ${serviceName ?? "unknown"}.`} fg={colors.muted} />
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
42
|
+
const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
|
|
43
|
+
const selectedLog = logsState.data[safeSelectedIndex] ?? null
|
|
44
|
+
const detailWidth = Math.max(16, contentWidth - 2)
|
|
45
|
+
const detailBodyLines = selectedLog ? wrapTextLines(selectedLog.body, detailWidth, 6) : []
|
|
46
|
+
const detailAttributeLines = selectedLog ? relevantLogAttributes(selectedLog).slice(0, 3) : []
|
|
47
|
+
const detailHeight = selectedLog ? 3 + detailBodyLines.length + detailAttributeLines.length : 0
|
|
48
|
+
const listHeight = Math.max(4, bodyLines - detailHeight - 1)
|
|
49
|
+
|
|
50
|
+
useLayoutEffect(() => {
|
|
51
|
+
scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
|
|
52
|
+
}, [safeSelectedIndex])
|
|
53
|
+
|
|
54
|
+
return (
|
|
55
|
+
<box flexDirection="column">
|
|
56
|
+
{selectedLog ? (
|
|
57
|
+
<>
|
|
58
|
+
<TextLine>
|
|
59
|
+
<span fg={logSeverityColor(selectedLog.severityText)}>{selectedLog.severityText.toLowerCase()}</span>
|
|
60
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
61
|
+
<span fg={colors.muted}>{formatLogTimestamp(selectedLog.timestamp)}</span>
|
|
62
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
63
|
+
<span fg={colors.count}>{selectedLog.traceId ? selectedLog.traceId.slice(-8) : "no-trace"}</span>
|
|
64
|
+
</TextLine>
|
|
65
|
+
<TextLine>
|
|
66
|
+
<span fg={colors.defaultService}>{selectedLog.scopeName ?? selectedLog.serviceName}</span>
|
|
67
|
+
{selectedLog.spanId ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{selectedLog.spanId.slice(-8)}</span></> : null}
|
|
68
|
+
</TextLine>
|
|
69
|
+
{detailBodyLines.map((line, index) => (
|
|
70
|
+
<PlainLine key={`log-detail-${selectedLog.id}-${index}`} text={line} fg={colors.text} />
|
|
71
|
+
))}
|
|
72
|
+
{detailAttributeLines.map(([key, value]) => (
|
|
73
|
+
<TextLine key={`log-attr-${selectedLog.id}-${key}`}>
|
|
74
|
+
<span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
|
|
75
|
+
<span fg={colors.muted}> </span>
|
|
76
|
+
<span fg={colors.muted}>{truncateText(value, Math.max(12, detailWidth - 20))}</span>
|
|
77
|
+
</TextLine>
|
|
78
|
+
))}
|
|
79
|
+
<Divider width={contentWidth} />
|
|
80
|
+
</>
|
|
81
|
+
) : null}
|
|
82
|
+
<TextLine fg={colors.muted}>
|
|
83
|
+
<span>{fitCell("time", timeWidth)}</span>
|
|
84
|
+
<span> </span>
|
|
85
|
+
<span>{fitCell("lvl", levelWidth)}</span>
|
|
86
|
+
<span> </span>
|
|
87
|
+
<span>{fitCell("trace", traceWidth)}</span>
|
|
88
|
+
<span> </span>
|
|
89
|
+
<span>{fitCell("message", messageWidth)}</span>
|
|
90
|
+
</TextLine>
|
|
91
|
+
<scrollbox ref={scrollRef} height={listHeight} flexGrow={0}>
|
|
92
|
+
{logsState.data.map((log, index) => {
|
|
93
|
+
const selected = index === safeSelectedIndex
|
|
94
|
+
const trace = log.traceId ? log.traceId.slice(-8) : "-"
|
|
95
|
+
return (
|
|
96
|
+
<box id={`svc-log-${index}`} key={log.id} height={1} onMouseDown={() => onSelectLog(index)}>
|
|
97
|
+
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
98
|
+
<span fg={colors.muted}>{fitCell(formatTimestamp(log.timestamp), timeWidth)}</span>
|
|
99
|
+
<span> </span>
|
|
100
|
+
<span fg={logSeverityColor(log.severityText)}>{fitCell(log.severityText.toLowerCase(), levelWidth)}</span>
|
|
101
|
+
<span> </span>
|
|
102
|
+
<span fg={colors.count}>{fitCell(trace, traceWidth)}</span>
|
|
103
|
+
<span> </span>
|
|
104
|
+
<span fg={selected ? colors.selectedText : colors.text}>{fitCell(logHeadline(log.body), messageWidth)}</span>
|
|
105
|
+
</TextLine>
|
|
106
|
+
</box>
|
|
107
|
+
)
|
|
108
|
+
})}
|
|
109
|
+
</scrollbox>
|
|
110
|
+
</box>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { LogItem, TraceSpanItem } from "../domain.ts"
|
|
3
|
+
import { formatTimestamp, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
|
|
4
|
+
import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
|
|
5
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
6
|
+
|
|
7
|
+
export const SpanDetailView = ({
|
|
8
|
+
span,
|
|
9
|
+
logs,
|
|
10
|
+
contentWidth,
|
|
11
|
+
bodyLines,
|
|
12
|
+
}: {
|
|
13
|
+
span: TraceSpanItem
|
|
14
|
+
logs: readonly LogItem[]
|
|
15
|
+
contentWidth: number
|
|
16
|
+
bodyLines: number
|
|
17
|
+
}) => {
|
|
18
|
+
const tagEntries = Object.entries(span.tags)
|
|
19
|
+
const maxKeyLen = Math.min(28, tagEntries.reduce((max, [key]) => Math.max(max, key.length), 0))
|
|
20
|
+
const maxLogLines = logs.length > 0 ? Math.min(4, Math.max(1, Math.floor(bodyLines * 0.3))) : 0
|
|
21
|
+
const visibleLogs = logs.slice(0, maxLogLines)
|
|
22
|
+
const visibleWarnings = span.warnings.slice(0, visibleLogs.length > 0 ? 1 : 2)
|
|
23
|
+
const visibleEvents = span.events.slice(0, 2)
|
|
24
|
+
const reservedForWarnings = visibleWarnings.length > 0 ? visibleWarnings.length + 2 : 0
|
|
25
|
+
const reservedForEvents = visibleEvents.length > 0 ? visibleEvents.length + 2 : 0
|
|
26
|
+
const reservedForLogs = visibleLogs.length > 0 ? visibleLogs.reduce((total, log) => total + 3 + Math.min(3, wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3).length), 1) : 0
|
|
27
|
+
const maxTagLines = Math.max(0, bodyLines - 4 - reservedForWarnings - reservedForEvents - reservedForLogs)
|
|
28
|
+
|
|
29
|
+
// NOTE: op name, service, duration, lifecycle, status, and spanId are all
|
|
30
|
+
// rendered by the enclosing SpanDetailPane header (rows 0..2). Starting
|
|
31
|
+
// the body at TAGS avoids the visible duplication where the pane meta
|
|
32
|
+
// and the first two body lines mirrored each other.
|
|
33
|
+
return (
|
|
34
|
+
<box flexDirection="column">
|
|
35
|
+
{tagEntries.length > 0 ? (
|
|
36
|
+
<>
|
|
37
|
+
<TextLine>
|
|
38
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
|
|
39
|
+
</TextLine>
|
|
40
|
+
{tagEntries.slice(0, maxTagLines).map(([key, value]) => {
|
|
41
|
+
const keyStr = key.length > maxKeyLen ? `${key.slice(0, maxKeyLen - 1)}\u2026` : key.padEnd(maxKeyLen)
|
|
42
|
+
const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 2)
|
|
43
|
+
const valStr = value.length > valMaxWidth ? `${value.slice(0, valMaxWidth - 1)}\u2026` : value
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<TextLine key={key}>
|
|
47
|
+
<span fg={colors.count}>{keyStr}</span>
|
|
48
|
+
<span fg={colors.muted}> </span>
|
|
49
|
+
<span fg={colors.text}>{valStr}</span>
|
|
50
|
+
</TextLine>
|
|
51
|
+
)
|
|
52
|
+
})}
|
|
53
|
+
{tagEntries.length > maxTagLines ? (
|
|
54
|
+
<PlainLine text={` \u2026 ${tagEntries.length - maxTagLines} more`} fg={colors.muted} />
|
|
55
|
+
) : null}
|
|
56
|
+
</>
|
|
57
|
+
) : (
|
|
58
|
+
<PlainLine text="No tags on this span." fg={colors.muted} />
|
|
59
|
+
)}
|
|
60
|
+
{visibleWarnings.length > 0 ? (
|
|
61
|
+
<>
|
|
62
|
+
<BlankRow />
|
|
63
|
+
<TextLine>
|
|
64
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>WARNINGS</span>
|
|
65
|
+
</TextLine>
|
|
66
|
+
{visibleWarnings.map((warning, i) => (
|
|
67
|
+
<PlainLine key={i} text={warning} fg={colors.error} />
|
|
68
|
+
))}
|
|
69
|
+
</>
|
|
70
|
+
) : null}
|
|
71
|
+
{visibleEvents.length > 0 ? (
|
|
72
|
+
<>
|
|
73
|
+
<BlankRow />
|
|
74
|
+
<TextLine>
|
|
75
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>EVENTS</span>
|
|
76
|
+
</TextLine>
|
|
77
|
+
{visibleEvents.map((event, index) => {
|
|
78
|
+
const preview = Object.entries(event.attributes).slice(0, 1)
|
|
79
|
+
return (
|
|
80
|
+
<box key={`${event.name}-${index}`} flexDirection="column">
|
|
81
|
+
<TextLine>
|
|
82
|
+
<span fg={colors.muted}>{formatTimestamp(event.timestamp)}</span>
|
|
83
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
84
|
+
<span fg={colors.text}>{event.name}</span>
|
|
85
|
+
</TextLine>
|
|
86
|
+
{preview.map(([key, value]) => (
|
|
87
|
+
<TextLine key={`${event.name}-${key}`}>
|
|
88
|
+
<span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
|
|
89
|
+
<span fg={colors.muted}> </span>
|
|
90
|
+
<span fg={colors.muted}>{truncateText(value, Math.max(12, contentWidth - 20))}</span>
|
|
91
|
+
</TextLine>
|
|
92
|
+
))}
|
|
93
|
+
</box>
|
|
94
|
+
)
|
|
95
|
+
})}
|
|
96
|
+
</>
|
|
97
|
+
) : null}
|
|
98
|
+
{visibleLogs.length > 0 ? (
|
|
99
|
+
<>
|
|
100
|
+
<BlankRow />
|
|
101
|
+
<TextLine>
|
|
102
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>RELATED LOGS</span>
|
|
103
|
+
</TextLine>
|
|
104
|
+
{visibleLogs.map((log) => {
|
|
105
|
+
const logBodyLines = wrapTextLines(log.body, Math.max(16, contentWidth - 2), 3)
|
|
106
|
+
const attributePreview = relevantLogAttributes(log).slice(0, 1)
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<box key={log.id} flexDirection="column">
|
|
110
|
+
<TextLine>
|
|
111
|
+
<span fg={colors.muted}>{formatTimestamp(log.timestamp)}</span>
|
|
112
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
113
|
+
<span fg={logSeverityColor(log.severityText)}>{log.severityText.toLowerCase()}</span>
|
|
114
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
115
|
+
<span fg={colors.defaultService}>{log.scopeName ?? log.serviceName}</span>
|
|
116
|
+
</TextLine>
|
|
117
|
+
{logBodyLines.map((line, index) => (
|
|
118
|
+
<PlainLine key={`${log.id}-body-${index}`} text={line} fg={colors.text} />
|
|
119
|
+
))}
|
|
120
|
+
{attributePreview.map(([key, value]) => (
|
|
121
|
+
<TextLine key={`${log.id}-${key}`}>
|
|
122
|
+
<span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
|
|
123
|
+
<span fg={colors.muted}> </span>
|
|
124
|
+
<span fg={colors.muted}>{truncateText(value, Math.max(12, contentWidth - 20))}</span>
|
|
125
|
+
</TextLine>
|
|
126
|
+
))}
|
|
127
|
+
</box>
|
|
128
|
+
)
|
|
129
|
+
})}
|
|
130
|
+
</>
|
|
131
|
+
) : null}
|
|
132
|
+
</box>
|
|
133
|
+
)
|
|
134
|
+
}
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import { TextAttributes } from "@opentui/core"
|
|
2
|
+
import type { ReactNode } from "react"
|
|
3
|
+
import type { LogItem, TraceSpanItem } from "../domain.ts"
|
|
4
|
+
import { formatDuration, formatShortDate, formatTimestamp, lifecycleLabel, logSeverityColor, relevantLogAttributes, truncateText, wrapTextLines } from "./format.ts"
|
|
5
|
+
import { BlankRow, PlainLine, TextLine } from "./primitives.tsx"
|
|
6
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
7
|
+
|
|
8
|
+
type Line = ReactNode
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Full-pane span detail view. Shows everything we know about a span:
|
|
12
|
+
* - identifiers, timing, scope, kind, status
|
|
13
|
+
* - all tags
|
|
14
|
+
* - all events with all attributes
|
|
15
|
+
* - all warnings
|
|
16
|
+
* - all correlated logs with wrapped bodies and relevant attributes
|
|
17
|
+
*
|
|
18
|
+
* The view builds a flat array of rendered lines then slices it to `bodyLines`,
|
|
19
|
+
* honoring a scroll offset so `j/k` scrolling can be wired later.
|
|
20
|
+
*/
|
|
21
|
+
export const SpanDetailFullView = ({
|
|
22
|
+
span,
|
|
23
|
+
logs,
|
|
24
|
+
contentWidth,
|
|
25
|
+
bodyLines,
|
|
26
|
+
scrollOffset = 0,
|
|
27
|
+
}: {
|
|
28
|
+
span: TraceSpanItem
|
|
29
|
+
logs: readonly LogItem[]
|
|
30
|
+
contentWidth: number
|
|
31
|
+
bodyLines: number
|
|
32
|
+
scrollOffset?: number
|
|
33
|
+
}) => {
|
|
34
|
+
const lines: Line[] = []
|
|
35
|
+
let key = 0
|
|
36
|
+
const push = (node: Line) => lines.push(<box key={key++}>{node}</box>)
|
|
37
|
+
|
|
38
|
+
// --- Header: operation + status line ---
|
|
39
|
+
push(
|
|
40
|
+
<TextLine>
|
|
41
|
+
<span fg={colors.text} attributes={TextAttributes.BOLD}>{span.operationName}</span>
|
|
42
|
+
</TextLine>,
|
|
43
|
+
)
|
|
44
|
+
push(
|
|
45
|
+
<TextLine>
|
|
46
|
+
<span fg={colors.defaultService}>{span.serviceName}</span>
|
|
47
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
48
|
+
<span fg={colors.count}>{formatDuration(span.durationMs)}</span>
|
|
49
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
50
|
+
<span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
|
|
51
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
52
|
+
<span fg={span.status === "error" ? colors.error : colors.passing}>{span.status}</span>
|
|
53
|
+
</TextLine>,
|
|
54
|
+
)
|
|
55
|
+
push(<BlankRow />)
|
|
56
|
+
|
|
57
|
+
// --- Metadata block ---
|
|
58
|
+
const metaKeyWidth = 10
|
|
59
|
+
const metaRow = (k: string, v: string, vFg: string = colors.text) => (
|
|
60
|
+
<TextLine>
|
|
61
|
+
<span fg={colors.count}>{k.padEnd(metaKeyWidth)}</span>
|
|
62
|
+
<span fg={colors.muted}> </span>
|
|
63
|
+
<span fg={vFg}>{truncateText(v, Math.max(8, contentWidth - metaKeyWidth - 1))}</span>
|
|
64
|
+
</TextLine>
|
|
65
|
+
)
|
|
66
|
+
push(<TextLine><span fg={colors.accent} attributes={TextAttributes.BOLD}>META</span></TextLine>)
|
|
67
|
+
push(metaRow("span id", span.spanId))
|
|
68
|
+
if (span.parentSpanId) push(metaRow("parent", span.parentSpanId))
|
|
69
|
+
if (span.kind) push(metaRow("kind", span.kind))
|
|
70
|
+
if (span.scopeName) push(metaRow("scope", span.scopeName))
|
|
71
|
+
push(metaRow("started", `${formatShortDate(span.startTime)} ${formatTimestamp(span.startTime)}`))
|
|
72
|
+
push(metaRow("depth", String(span.depth), colors.muted))
|
|
73
|
+
|
|
74
|
+
// --- Tags ---
|
|
75
|
+
const tagEntries = Object.entries(span.tags)
|
|
76
|
+
if (tagEntries.length > 0) {
|
|
77
|
+
push(<BlankRow />)
|
|
78
|
+
push(
|
|
79
|
+
<TextLine>
|
|
80
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>TAGS</span>
|
|
81
|
+
<span fg={colors.muted}> ({tagEntries.length})</span>
|
|
82
|
+
</TextLine>,
|
|
83
|
+
)
|
|
84
|
+
const maxKeyLen = Math.min(32, tagEntries.reduce((m, [k]) => Math.max(m, k.length), 0))
|
|
85
|
+
const valMaxWidth = Math.max(8, contentWidth - maxKeyLen - 2)
|
|
86
|
+
for (const [tagKey, value] of tagEntries) {
|
|
87
|
+
const keyStr = tagKey.length > maxKeyLen ? `${tagKey.slice(0, maxKeyLen - 1)}\u2026` : tagKey.padEnd(maxKeyLen)
|
|
88
|
+
const wrapped = wrapTextLines(value, valMaxWidth, 4)
|
|
89
|
+
wrapped.forEach((line, idx) => {
|
|
90
|
+
push(
|
|
91
|
+
<TextLine>
|
|
92
|
+
<span fg={colors.count}>{idx === 0 ? keyStr : " ".repeat(maxKeyLen)}</span>
|
|
93
|
+
<span fg={colors.muted}> </span>
|
|
94
|
+
<span fg={colors.text}>{line}</span>
|
|
95
|
+
</TextLine>,
|
|
96
|
+
)
|
|
97
|
+
})
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --- Warnings ---
|
|
102
|
+
if (span.warnings.length > 0) {
|
|
103
|
+
push(<BlankRow />)
|
|
104
|
+
push(
|
|
105
|
+
<TextLine>
|
|
106
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>WARNINGS</span>
|
|
107
|
+
<span fg={colors.muted}> ({span.warnings.length})</span>
|
|
108
|
+
</TextLine>,
|
|
109
|
+
)
|
|
110
|
+
for (const warning of span.warnings) {
|
|
111
|
+
for (const line of wrapTextLines(warning, Math.max(16, contentWidth - 2), 4)) {
|
|
112
|
+
push(<PlainLine text={line} fg={colors.error} />)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// --- Events ---
|
|
118
|
+
if (span.events.length > 0) {
|
|
119
|
+
push(<BlankRow />)
|
|
120
|
+
push(
|
|
121
|
+
<TextLine>
|
|
122
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>EVENTS</span>
|
|
123
|
+
<span fg={colors.muted}> ({span.events.length})</span>
|
|
124
|
+
</TextLine>,
|
|
125
|
+
)
|
|
126
|
+
for (const event of span.events) {
|
|
127
|
+
push(
|
|
128
|
+
<TextLine>
|
|
129
|
+
<span fg={colors.muted}>{formatTimestamp(event.timestamp)}</span>
|
|
130
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
131
|
+
<span fg={colors.text}>{event.name}</span>
|
|
132
|
+
</TextLine>,
|
|
133
|
+
)
|
|
134
|
+
const attrs = Object.entries(event.attributes)
|
|
135
|
+
if (attrs.length > 0) {
|
|
136
|
+
const attrKeyWidth = Math.min(24, attrs.reduce((m, [k]) => Math.max(m, k.length), 0))
|
|
137
|
+
const attrValWidth = Math.max(8, contentWidth - attrKeyWidth - 4)
|
|
138
|
+
for (const [attrKey, attrVal] of attrs) {
|
|
139
|
+
const wrapped = wrapTextLines(attrVal, attrValWidth, 2)
|
|
140
|
+
wrapped.forEach((line, idx) => {
|
|
141
|
+
push(
|
|
142
|
+
<TextLine>
|
|
143
|
+
<span fg={colors.muted}> </span>
|
|
144
|
+
<span fg={colors.count}>
|
|
145
|
+
{idx === 0
|
|
146
|
+
? (attrKey.length > attrKeyWidth ? `${attrKey.slice(0, attrKeyWidth - 1)}\u2026` : attrKey.padEnd(attrKeyWidth))
|
|
147
|
+
: " ".repeat(attrKeyWidth)}
|
|
148
|
+
</span>
|
|
149
|
+
<span fg={colors.muted}> </span>
|
|
150
|
+
<span fg={colors.muted}>{line}</span>
|
|
151
|
+
</TextLine>,
|
|
152
|
+
)
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// --- Logs ---
|
|
160
|
+
if (logs.length > 0) {
|
|
161
|
+
push(<BlankRow />)
|
|
162
|
+
push(
|
|
163
|
+
<TextLine>
|
|
164
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>LOGS</span>
|
|
165
|
+
<span fg={colors.muted}> ({logs.length})</span>
|
|
166
|
+
</TextLine>,
|
|
167
|
+
)
|
|
168
|
+
for (const log of logs) {
|
|
169
|
+
push(
|
|
170
|
+
<TextLine>
|
|
171
|
+
<span fg={colors.muted}>{formatTimestamp(log.timestamp)}</span>
|
|
172
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
173
|
+
<span fg={logSeverityColor(log.severityText)}>{log.severityText.toLowerCase()}</span>
|
|
174
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
175
|
+
<span fg={colors.defaultService}>{log.scopeName ?? log.serviceName}</span>
|
|
176
|
+
</TextLine>,
|
|
177
|
+
)
|
|
178
|
+
for (const line of wrapTextLines(log.body, Math.max(16, contentWidth - 2), 8)) {
|
|
179
|
+
push(<PlainLine text={line} fg={colors.text} />)
|
|
180
|
+
}
|
|
181
|
+
const attrs = relevantLogAttributes(log)
|
|
182
|
+
if (attrs.length > 0) {
|
|
183
|
+
const attrKeyWidth = Math.min(22, attrs.reduce((m, [k]) => Math.max(m, k.length), 0))
|
|
184
|
+
const attrValWidth = Math.max(8, contentWidth - attrKeyWidth - 4)
|
|
185
|
+
for (const [attrKey, attrVal] of attrs) {
|
|
186
|
+
const wrapped = wrapTextLines(attrVal, attrValWidth, 2)
|
|
187
|
+
wrapped.forEach((line, idx) => {
|
|
188
|
+
push(
|
|
189
|
+
<TextLine>
|
|
190
|
+
<span fg={colors.muted}> </span>
|
|
191
|
+
<span fg={colors.count}>
|
|
192
|
+
{idx === 0
|
|
193
|
+
? (attrKey.length > attrKeyWidth ? `${attrKey.slice(0, attrKeyWidth - 1)}\u2026` : attrKey.padEnd(attrKeyWidth))
|
|
194
|
+
: " ".repeat(attrKeyWidth)}
|
|
195
|
+
</span>
|
|
196
|
+
<span fg={colors.muted}> </span>
|
|
197
|
+
<span fg={colors.muted}>{line}</span>
|
|
198
|
+
</TextLine>,
|
|
199
|
+
)
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// --- Slice by scroll ---
|
|
207
|
+
const totalLines = lines.length
|
|
208
|
+
const maxOffset = Math.max(0, totalLines - bodyLines)
|
|
209
|
+
const offset = Math.min(Math.max(0, scrollOffset), maxOffset)
|
|
210
|
+
const visible = lines.slice(offset, offset + bodyLines)
|
|
211
|
+
|
|
212
|
+
return (
|
|
213
|
+
<box flexDirection="column">
|
|
214
|
+
{visible}
|
|
215
|
+
{totalLines > bodyLines ? (
|
|
216
|
+
<TextLine>
|
|
217
|
+
<span fg={colors.muted}>
|
|
218
|
+
{`\u2014 ${offset + 1}\u2013${Math.min(offset + bodyLines, totalLines)} of ${totalLines} lines${offset < maxOffset ? " \u00b7 j/k to scroll" : ""}`}
|
|
219
|
+
</span>
|
|
220
|
+
</TextLine>
|
|
221
|
+
) : null}
|
|
222
|
+
</box>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import type { LogItem, TraceItem, TraceSpanItem } from "../domain.ts"
|
|
2
|
+
import { formatDuration, lifecycleLabel } from "./format.ts"
|
|
3
|
+
import { AlignedHeaderLine, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
4
|
+
import { SpanDetailView } from "./SpanDetail.tsx"
|
|
5
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Level-2 view: focused span. Renders a header matching
|
|
9
|
+
* `TraceDetailsPane` geometry (so wide-mode side-by-side layouts line up
|
|
10
|
+
* vertically) plus a body that can grow to fill the available height.
|
|
11
|
+
*
|
|
12
|
+
* Total height: `bodyLines + HEADER_ROWS`.
|
|
13
|
+
*/
|
|
14
|
+
export const SPAN_DETAIL_HEADER_ROWS = 4
|
|
15
|
+
|
|
16
|
+
export const SpanDetailPane = ({
|
|
17
|
+
span,
|
|
18
|
+
trace,
|
|
19
|
+
logs,
|
|
20
|
+
contentWidth,
|
|
21
|
+
bodyLines,
|
|
22
|
+
paneWidth,
|
|
23
|
+
focused = false,
|
|
24
|
+
}: {
|
|
25
|
+
span: TraceSpanItem | null
|
|
26
|
+
trace: TraceItem | null
|
|
27
|
+
logs: readonly LogItem[]
|
|
28
|
+
contentWidth: number
|
|
29
|
+
bodyLines: number
|
|
30
|
+
paneWidth: number
|
|
31
|
+
focused?: boolean
|
|
32
|
+
}) => {
|
|
33
|
+
const focusIndicator = focused ? "\u25b8 " : ""
|
|
34
|
+
const headerTitle = `${focusIndicator}SPAN`
|
|
35
|
+
const headerRight = span
|
|
36
|
+
? `${span.status} \u00b7 ${formatDuration(span.durationMs)}${logs.length > 0 ? ` \u00b7 ${logs.length} lg` : ""}`
|
|
37
|
+
: "no span selected"
|
|
38
|
+
const headerColor = span
|
|
39
|
+
? span.isRunning
|
|
40
|
+
? colors.warning
|
|
41
|
+
: span.status === "error"
|
|
42
|
+
? colors.error
|
|
43
|
+
: colors.passing
|
|
44
|
+
: colors.muted
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + SPAN_DETAIL_HEADER_ROWS} overflow="hidden">
|
|
48
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
49
|
+
<AlignedHeaderLine left={headerTitle} right={headerRight} width={contentWidth} rightFg={headerColor} />
|
|
50
|
+
</box>
|
|
51
|
+
{span && trace ? (
|
|
52
|
+
<>
|
|
53
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
54
|
+
<TextLine>
|
|
55
|
+
<span fg={colors.text}>{span.operationName}</span>
|
|
56
|
+
</TextLine>
|
|
57
|
+
<TextLine>
|
|
58
|
+
<span fg={colors.defaultService}>{span.serviceName}</span>
|
|
59
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
60
|
+
<span fg={colors.muted}>{span.scopeName ?? "no scope"}</span>
|
|
61
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
62
|
+
<span fg={span.isRunning ? colors.warning : colors.muted}>{lifecycleLabel(span)}</span>
|
|
63
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
64
|
+
<span fg={colors.muted}>{span.spanId.slice(0, 16)}</span>
|
|
65
|
+
</TextLine>
|
|
66
|
+
</box>
|
|
67
|
+
<Divider width={paneWidth} />
|
|
68
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
69
|
+
<SpanDetailView
|
|
70
|
+
span={span}
|
|
71
|
+
logs={logs}
|
|
72
|
+
contentWidth={contentWidth}
|
|
73
|
+
bodyLines={bodyLines}
|
|
74
|
+
/>
|
|
75
|
+
</box>
|
|
76
|
+
</>
|
|
77
|
+
) : (
|
|
78
|
+
<>
|
|
79
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
80
|
+
<TextLine><span fg={colors.muted}>—</span></TextLine>
|
|
81
|
+
<TextLine><span fg={colors.muted}>—</span></TextLine>
|
|
82
|
+
</box>
|
|
83
|
+
<Divider width={paneWidth} />
|
|
84
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
85
|
+
<PlainLine text="Select a span in the waterfall to view its detail." fg={colors.muted} />
|
|
86
|
+
</box>
|
|
87
|
+
</>
|
|
88
|
+
)}
|
|
89
|
+
</box>
|
|
90
|
+
)
|
|
91
|
+
}
|