@aryasaatvik/motel 0.3.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 +180 -0
- package/LICENSE +21 -0
- package/README.md +137 -0
- package/package.json +119 -0
- package/skills/motel-debug/SKILL.md +203 -0
- package/skills/motel-debug/references/effect.md +38 -0
- package/src/App.tsx +414 -0
- package/src/StartupGate.test.ts +80 -0
- package/src/StartupGate.tsx +263 -0
- package/src/cli.ts +205 -0
- package/src/config.ts +45 -0
- package/src/daemon.test.ts +547 -0
- package/src/daemon.ts +486 -0
- package/src/domain.test.ts +62 -0
- package/src/domain.ts +291 -0
- package/src/httpApi.ts +391 -0
- package/src/httpListPolicy.test.ts +76 -0
- package/src/httpListPolicy.ts +129 -0
- package/src/index.tsx +25 -0
- package/src/instructions.ts +72 -0
- package/src/launchAgent.test.ts +430 -0
- package/src/launchAgent.ts +462 -0
- package/src/localServer.ts +568 -0
- package/src/locator.ts +138 -0
- package/src/mcp.ts +433 -0
- package/src/motel.test.ts +176 -0
- package/src/motel.ts +88 -0
- package/src/motelClient.ts +353 -0
- package/src/opentui-jsx.d.ts +11 -0
- package/src/otlp.test.ts +65 -0
- package/src/otlp.ts +162 -0
- package/src/otlpProtobuf.ts +35 -0
- package/src/queryFilters.ts +39 -0
- package/src/registry.ts +115 -0
- package/src/runtime.ts +40 -0
- package/src/server.ts +11 -0
- package/src/services/AsyncIngest.ts +82 -0
- package/src/services/LogQueryService.ts +29 -0
- package/src/services/TelemetryQuery.ts +62 -0
- package/src/services/TelemetryStore.ts +2554 -0
- package/src/services/TraceQueryService.ts +43 -0
- package/src/services/ingestRpc.ts +39 -0
- package/src/services/queryRpc.ts +15 -0
- package/src/services/telemetryQueryWorker.ts +32 -0
- package/src/services/telemetryWorker.ts +59 -0
- package/src/startupBench.ts +19 -0
- package/src/startupLifecycle.ts +57 -0
- package/src/storybook/aiChatStory.tsx +244 -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 +1053 -0
- package/src/ui/AiChatView.tsx +308 -0
- package/src/ui/AttrFilterModal.tsx +120 -0
- package/src/ui/ServiceLogs.tsx +115 -0
- package/src/ui/SpanContentView.tsx +184 -0
- package/src/ui/SpanDetail.tsx +215 -0
- package/src/ui/SpanDetailFull.tsx +224 -0
- package/src/ui/SpanDetailPane.tsx +90 -0
- package/src/ui/TraceDetailsPane.tsx +175 -0
- package/src/ui/TraceList.tsx +254 -0
- package/src/ui/Waterfall.tsx +389 -0
- package/src/ui/aiChatModel.test.ts +391 -0
- package/src/ui/aiChatModel.ts +773 -0
- package/src/ui/aiState.ts +59 -0
- package/src/ui/app/TraceListPane.tsx +39 -0
- package/src/ui/app/TraceWorkspace.tsx +452 -0
- package/src/ui/app/useAppLayout.ts +101 -0
- package/src/ui/app/useTraceScreenData.ts +590 -0
- package/src/ui/atoms.ts +131 -0
- package/src/ui/cachedLoader.test.ts +23 -0
- package/src/ui/cachedLoader.ts +60 -0
- package/src/ui/filterParser.test.ts +56 -0
- package/src/ui/filterParser.ts +45 -0
- package/src/ui/format.ts +128 -0
- package/src/ui/loaders.ts +101 -0
- package/src/ui/persistence.ts +41 -0
- package/src/ui/primitives.tsx +198 -0
- package/src/ui/state.ts +6 -0
- package/src/ui/theme.ts +160 -0
- package/src/ui/traceDetailsWidth.repro.test.ts +126 -0
- package/src/ui/traceSortNav.repro.seed.ts +62 -0
- package/src/ui/traceSortNav.repro.test.ts +231 -0
- package/src/ui/useAttrFilterPicker.ts +89 -0
- package/src/ui/useKeyboardNav.ts +1023 -0
- 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.repro.seed.ts +86 -0
- package/src/ui/waterfallNav.repro.test.ts +271 -0
- package/src/ui/waterfallNav.test.ts +453 -0
- package/src/ui/waterfallNav.ts +75 -0
- package/web/dist/assets/index-B01z9BaO.css +2 -0
- package/web/dist/assets/index-CYlBaY9O.js +22 -0
- package/web/dist/index.html +13 -0
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import { RGBA, TextAttributes } from "@opentui/core"
|
|
2
|
+
import { useLayoutEffect, useMemo, useState } from "react"
|
|
3
|
+
import { isAiSpan, type TraceSpanItem } from "../domain.ts"
|
|
4
|
+
import {
|
|
5
|
+
buildChatListRows,
|
|
6
|
+
chunkDetailTitle,
|
|
7
|
+
renderChunkDetailLines,
|
|
8
|
+
type Chunk,
|
|
9
|
+
type ChatListRow,
|
|
10
|
+
type Role,
|
|
11
|
+
} from "./aiChatModel.ts"
|
|
12
|
+
import { formatDuration, truncateText } from "./format.ts"
|
|
13
|
+
import { AlignedHeaderLine, BlankRow, Divider, PlainLine, TextLine } from "./primitives.tsx"
|
|
14
|
+
import type { AiCallDetailState } from "./aiState.ts"
|
|
15
|
+
import { colors, SEPARATOR } from "./theme.ts"
|
|
16
|
+
|
|
17
|
+
export const AI_CHAT_HEADER_ROWS = 4
|
|
18
|
+
|
|
19
|
+
const roleColor = (role: Role): string => {
|
|
20
|
+
switch (role) {
|
|
21
|
+
case "user": return colors.accent
|
|
22
|
+
case "assistant": return colors.text
|
|
23
|
+
case "system": return colors.muted
|
|
24
|
+
case "tool": return colors.passing
|
|
25
|
+
case "response": return colors.accent
|
|
26
|
+
default: return colors.muted
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const rowPrefix = (chunk: Chunk | null): string => {
|
|
31
|
+
if (!chunk) return " "
|
|
32
|
+
switch (chunk.kind) {
|
|
33
|
+
case "tool-call": return "→ "
|
|
34
|
+
case "tool-result": return "← "
|
|
35
|
+
case "reasoning": return "• "
|
|
36
|
+
case "response": return "↳ "
|
|
37
|
+
default: return ""
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const rowTextColor = (chunk: Chunk | null, role: Role, selected: boolean): string => {
|
|
42
|
+
if (selected) return colors.selectedText
|
|
43
|
+
if (!chunk) return roleColor(role)
|
|
44
|
+
if (chunk.kind === "tool-call") return colors.count
|
|
45
|
+
if (chunk.kind === "tool-result") return colors.passing
|
|
46
|
+
if (chunk.kind === "reasoning") return colors.muted
|
|
47
|
+
if (chunk.kind === "system") return colors.muted
|
|
48
|
+
return colors.text
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const splitToolRowText = (text: string): { readonly head: string; readonly tail: string | null } => {
|
|
52
|
+
const match = text.match(/\s{2,}/)
|
|
53
|
+
const sep = match?.index ?? -1
|
|
54
|
+
if (sep < 0) return { head: text, tail: null }
|
|
55
|
+
return {
|
|
56
|
+
head: text.slice(0, sep),
|
|
57
|
+
tail: text.slice(sep + match![0].length),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const clamp = (n: number, min: number, max: number) => Math.max(min, Math.min(max, n))
|
|
62
|
+
|
|
63
|
+
const chunkRows = (rows: readonly ChatListRow[]) => rows.filter((row) => row.kind === "chunk")
|
|
64
|
+
|
|
65
|
+
interface MouseScrollEvent {
|
|
66
|
+
readonly scroll?: {
|
|
67
|
+
readonly direction: string
|
|
68
|
+
readonly delta: number
|
|
69
|
+
}
|
|
70
|
+
readonly stopPropagation?: () => void
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const scrollDelta = (event: MouseScrollEvent): number => {
|
|
74
|
+
const info = event.scroll
|
|
75
|
+
if (!info) return 0
|
|
76
|
+
const magnitude = Math.max(1, Math.round(info.delta))
|
|
77
|
+
if (info.direction === "up") return -magnitude
|
|
78
|
+
if (info.direction === "down") return magnitude
|
|
79
|
+
return 0
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const ChatDetailModal = ({
|
|
83
|
+
chunk,
|
|
84
|
+
scrollOffset,
|
|
85
|
+
onScrollOffset,
|
|
86
|
+
paneWidth,
|
|
87
|
+
paneHeight,
|
|
88
|
+
onClose,
|
|
89
|
+
}: {
|
|
90
|
+
readonly chunk: Chunk
|
|
91
|
+
readonly scrollOffset: number
|
|
92
|
+
readonly onScrollOffset: (updater: (current: number) => number) => void
|
|
93
|
+
readonly paneWidth: number
|
|
94
|
+
readonly paneHeight: number
|
|
95
|
+
readonly onClose: () => void
|
|
96
|
+
}) => {
|
|
97
|
+
const modalWidth = Math.min(Math.max(56, Math.floor(paneWidth * 0.8)), paneWidth - 4)
|
|
98
|
+
const modalHeight = Math.min(Math.max(12, Math.floor(paneHeight * 0.75)), paneHeight - 2)
|
|
99
|
+
const left = Math.max(2, Math.floor((paneWidth - modalWidth) / 2))
|
|
100
|
+
const top = Math.max(1, Math.floor((paneHeight - modalHeight) / 2))
|
|
101
|
+
const innerWidth = Math.max(16, modalWidth - 4)
|
|
102
|
+
const bodyLines = Math.max(4, modalHeight - 4)
|
|
103
|
+
const lines = renderChunkDetailLines(chunk, innerWidth)
|
|
104
|
+
const maxOffset = Math.max(0, lines.length - bodyLines)
|
|
105
|
+
const offset = clamp(scrollOffset, 0, maxOffset)
|
|
106
|
+
const visible = lines.slice(offset, offset + bodyLines)
|
|
107
|
+
const meta = chunk.headerMeta ?? `${lines.length} lines`
|
|
108
|
+
const handleWheel = (event: MouseScrollEvent) => {
|
|
109
|
+
const delta = scrollDelta(event)
|
|
110
|
+
if (delta === 0) return
|
|
111
|
+
onScrollOffset((current) => clamp(current + delta, 0, maxOffset))
|
|
112
|
+
event.stopPropagation?.()
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<box position="absolute" zIndex={3500} left={0} top={0} width={paneWidth} height={paneHeight} backgroundColor={RGBA.fromInts(0, 0, 0, 110)} onMouseUp={onClose}>
|
|
117
|
+
<box position="absolute" left={left} top={top} width={modalWidth} height={modalHeight} flexDirection="column" backgroundColor={RGBA.fromHex(colors.screenBg)} onMouseScroll={handleWheel}>
|
|
118
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
119
|
+
<AlignedHeaderLine left={chunkDetailTitle(chunk)} right={`${meta} ${SEPARATOR} esc close`} width={modalWidth - 2} rightFg={colors.count} />
|
|
120
|
+
</box>
|
|
121
|
+
<Divider width={modalWidth} />
|
|
122
|
+
<box flexDirection="column" paddingLeft={2} paddingRight={2}>
|
|
123
|
+
{visible.map((line, i) => (
|
|
124
|
+
<PlainLine key={`detail-${i + offset}`} text={line} fg={colors.text} />
|
|
125
|
+
))}
|
|
126
|
+
{visible.length < bodyLines ? Array.from({ length: bodyLines - visible.length }, (_, i) => <BlankRow key={`detail-pad-${i}`} />) : null}
|
|
127
|
+
</box>
|
|
128
|
+
</box>
|
|
129
|
+
</box>
|
|
130
|
+
)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export const AiChatView = ({
|
|
134
|
+
span,
|
|
135
|
+
detailState,
|
|
136
|
+
chunks,
|
|
137
|
+
selectedChunkId,
|
|
138
|
+
onSelectChunk,
|
|
139
|
+
detailChunkId,
|
|
140
|
+
onOpenDetail,
|
|
141
|
+
onCloseDetail,
|
|
142
|
+
detailScrollOffset,
|
|
143
|
+
onSetDetailScrollOffset,
|
|
144
|
+
contentWidth,
|
|
145
|
+
bodyLines,
|
|
146
|
+
paneWidth,
|
|
147
|
+
}: {
|
|
148
|
+
readonly span: TraceSpanItem | null
|
|
149
|
+
readonly detailState: AiCallDetailState
|
|
150
|
+
readonly chunks: readonly Chunk[]
|
|
151
|
+
readonly selectedChunkId: string | null
|
|
152
|
+
readonly onSelectChunk: (chunkId: string) => void
|
|
153
|
+
readonly detailChunkId: string | null
|
|
154
|
+
readonly onOpenDetail: (chunkId: string) => void
|
|
155
|
+
readonly onCloseDetail: () => void
|
|
156
|
+
readonly detailScrollOffset: number
|
|
157
|
+
readonly onSetDetailScrollOffset: (updater: (current: number) => number) => void
|
|
158
|
+
readonly contentWidth: number
|
|
159
|
+
readonly bodyLines: number
|
|
160
|
+
readonly paneWidth: number
|
|
161
|
+
}) => {
|
|
162
|
+
const rows = useMemo(() => buildChatListRows(chunks), [chunks])
|
|
163
|
+
const selectable = useMemo(() => chunkRows(rows), [rows])
|
|
164
|
+
const chunkById = useMemo(() => new Map(chunks.map((chunk) => [chunk.id, chunk] as const)), [chunks])
|
|
165
|
+
const selectedOrdinal = useMemo(
|
|
166
|
+
() => selectedChunkId ? selectable.findIndex((row) => row.chunkId === selectedChunkId) : -1,
|
|
167
|
+
[selectable, selectedChunkId],
|
|
168
|
+
)
|
|
169
|
+
const [scrollOffset, setScrollOffset] = useState(0)
|
|
170
|
+
|
|
171
|
+
const detailChunk = useMemo(
|
|
172
|
+
() => detailChunkId ? chunkById.get(detailChunkId) ?? null : null,
|
|
173
|
+
[chunkById, detailChunkId],
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
const selectedRowIndex = useMemo(
|
|
177
|
+
() => selectedChunkId ? rows.findIndex((row) => row.kind === "chunk" && row.chunkId === selectedChunkId) : -1,
|
|
178
|
+
[rows, selectedChunkId],
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
useLayoutEffect(() => {
|
|
182
|
+
const maxOffset = Math.max(0, rows.length - bodyLines)
|
|
183
|
+
if (selectedRowIndex < 0) {
|
|
184
|
+
setScrollOffset((current) => clamp(current, 0, maxOffset))
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
setScrollOffset((current) => {
|
|
188
|
+
let next = clamp(current, 0, maxOffset)
|
|
189
|
+
if (selectedRowIndex < next) next = selectedRowIndex
|
|
190
|
+
else if (selectedRowIndex >= next + bodyLines) next = selectedRowIndex - bodyLines + 1
|
|
191
|
+
return clamp(next, 0, maxOffset)
|
|
192
|
+
})
|
|
193
|
+
}, [rows.length, bodyLines, selectedRowIndex])
|
|
194
|
+
|
|
195
|
+
if (!span || !isAiSpan(span.tags)) {
|
|
196
|
+
return (
|
|
197
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + AI_CHAT_HEADER_ROWS} overflow="hidden">
|
|
198
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
199
|
+
<AlignedHeaderLine left="AI CHAT" right="not an ai span" width={contentWidth} rightFg={colors.muted} />
|
|
200
|
+
</box>
|
|
201
|
+
</box>
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const detail = detailState.data
|
|
206
|
+
const model = detail?.model ?? span.tags["ai.model.id"] ?? "unknown model"
|
|
207
|
+
const provider = detail?.provider ?? span.tags["ai.model.provider"] ?? null
|
|
208
|
+
const operation = detail?.operation ?? span.operationName
|
|
209
|
+
const finishReason = detail?.finishReason ?? null
|
|
210
|
+
const usage = detail?.usage ?? null
|
|
211
|
+
const durationLabel = formatDuration(detail?.durationMs ?? span.durationMs)
|
|
212
|
+
const maxOffset = Math.max(0, rows.length - bodyLines)
|
|
213
|
+
const offset = clamp(scrollOffset, 0, maxOffset)
|
|
214
|
+
const visible = rows.slice(offset, offset + bodyLines)
|
|
215
|
+
const headerRight = `${operation} ${SEPARATOR} ${durationLabel} ${SEPARATOR} ${selectable.length > 0 ? `${Math.max(1, selectedOrdinal + 1)}/${selectable.length}` : "0/0"}`
|
|
216
|
+
const handleListWheel = (event: MouseScrollEvent) => {
|
|
217
|
+
if (detailChunk) return
|
|
218
|
+
const delta = scrollDelta(event)
|
|
219
|
+
if (delta === 0) return
|
|
220
|
+
setScrollOffset((current) => clamp(current + delta, 0, maxOffset))
|
|
221
|
+
event.stopPropagation?.()
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<box flexDirection="column" width={paneWidth} height={bodyLines + AI_CHAT_HEADER_ROWS} overflow="hidden">
|
|
226
|
+
<box paddingLeft={1} paddingRight={1}>
|
|
227
|
+
<AlignedHeaderLine left="AI CHAT" right={headerRight} width={contentWidth} rightFg={colors.count} />
|
|
228
|
+
</box>
|
|
229
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1}>
|
|
230
|
+
<TextLine>
|
|
231
|
+
<span fg={colors.accent} attributes={TextAttributes.BOLD}>{"✦ "}</span>
|
|
232
|
+
<span fg={colors.text}>{model}</span>
|
|
233
|
+
{provider ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{provider}</span></> : null}
|
|
234
|
+
{finishReason ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{`finish=${finishReason}`}</span></> : null}
|
|
235
|
+
</TextLine>
|
|
236
|
+
<TextLine>
|
|
237
|
+
{usage ? (
|
|
238
|
+
<>
|
|
239
|
+
<span fg={colors.muted}>{"tokens "}</span>
|
|
240
|
+
<span fg={colors.count}>{usage.inputTokens != null ? `in=${usage.inputTokens}` : ""}</span>
|
|
241
|
+
<span fg={colors.muted}>{usage.cachedInputTokens != null ? ` cached=${usage.cachedInputTokens}` : ""}</span>
|
|
242
|
+
<span fg={colors.count}>{usage.outputTokens != null ? ` out=${usage.outputTokens}` : ""}</span>
|
|
243
|
+
<span fg={colors.muted}>{usage.reasoningTokens != null ? ` reason=${usage.reasoningTokens}` : ""}</span>
|
|
244
|
+
</>
|
|
245
|
+
) : (
|
|
246
|
+
<span fg={colors.muted}>{detail?.sessionId ? `session ${detail.sessionId}` : "no usage reported"}</span>
|
|
247
|
+
)}
|
|
248
|
+
</TextLine>
|
|
249
|
+
</box>
|
|
250
|
+
<Divider width={paneWidth} />
|
|
251
|
+
<box flexDirection="column" paddingLeft={1} paddingRight={1} onMouseScroll={handleListWheel}>
|
|
252
|
+
{detailState.status === "loading" && !detail ? (
|
|
253
|
+
<PlainLine text="loading chat transcript…" fg={colors.muted} />
|
|
254
|
+
) : detailState.status === "error" ? (
|
|
255
|
+
<PlainLine text={detailState.error ?? "failed to load chat detail"} fg={colors.error} />
|
|
256
|
+
) : rows.length === 0 ? (
|
|
257
|
+
<PlainLine text="no chat content parsed from this span" fg={colors.muted} />
|
|
258
|
+
) : (
|
|
259
|
+
visible.map((row, i) => {
|
|
260
|
+
if (row.kind === "separator") {
|
|
261
|
+
return <BlankRow key={`row-${offset + i}`} />
|
|
262
|
+
}
|
|
263
|
+
if (row.kind === "role-divider") {
|
|
264
|
+
return (
|
|
265
|
+
<TextLine key={`row-${offset + i}`}>
|
|
266
|
+
<span fg={colors.separator}>{" "}</span>
|
|
267
|
+
<span fg={roleColor(row.role)} attributes={TextAttributes.BOLD}>{row.text}</span>
|
|
268
|
+
</TextLine>
|
|
269
|
+
)
|
|
270
|
+
}
|
|
271
|
+
const chunk = row.chunkId ? chunkById.get(row.chunkId) ?? null : null
|
|
272
|
+
const isSelected = row.chunkId === selectedChunkId
|
|
273
|
+
const prefix = rowPrefix(chunk)
|
|
274
|
+
const meta = row.meta ?? ""
|
|
275
|
+
const textWidth = Math.max(8, contentWidth - prefix.length - meta.length - 4)
|
|
276
|
+
const display = truncateText(row.text, textWidth)
|
|
277
|
+
const gap = Math.max(1, contentWidth - prefix.length - display.length - meta.length - 1)
|
|
278
|
+
const toolLike = chunk?.kind === "tool-call" || chunk?.kind === "tool-result"
|
|
279
|
+
const { head, tail } = toolLike ? splitToolRowText(display) : { head: display, tail: null }
|
|
280
|
+
const headColor = rowTextColor(chunk, row.role, isSelected)
|
|
281
|
+
const tailColor = isSelected ? colors.muted : colors.separator
|
|
282
|
+
return (
|
|
283
|
+
<box key={`row-${offset + i}`} height={1} onMouseDown={() => { if (row.chunkId) onSelectChunk(row.chunkId) }}>
|
|
284
|
+
<TextLine bg={isSelected ? colors.selectedBg : undefined}>
|
|
285
|
+
<span fg={isSelected ? roleColor(row.role) : colors.separator}>{isSelected ? "▎" : " "}</span>
|
|
286
|
+
<span fg={headColor} attributes={isSelected ? TextAttributes.BOLD : undefined}>{`${prefix}${head}`}</span>
|
|
287
|
+
{tail ? <span fg={tailColor}>{` ${tail}`}</span> : null}
|
|
288
|
+
{meta ? <><span fg={colors.muted}>{" ".repeat(gap)}</span><span fg={colors.muted}>{meta}</span></> : null}
|
|
289
|
+
</TextLine>
|
|
290
|
+
</box>
|
|
291
|
+
)
|
|
292
|
+
})
|
|
293
|
+
)}
|
|
294
|
+
{visible.length < bodyLines && rows.length > 0 ? Array.from({ length: bodyLines - visible.length }, (_, i) => <BlankRow key={`pad-${i}`} />) : null}
|
|
295
|
+
</box>
|
|
296
|
+
{detailChunk ? (
|
|
297
|
+
<ChatDetailModal
|
|
298
|
+
chunk={detailChunk}
|
|
299
|
+
scrollOffset={detailScrollOffset}
|
|
300
|
+
onScrollOffset={onSetDetailScrollOffset}
|
|
301
|
+
paneWidth={paneWidth}
|
|
302
|
+
paneHeight={bodyLines + AI_CHAT_HEADER_ROWS}
|
|
303
|
+
onClose={onCloseDetail}
|
|
304
|
+
/>
|
|
305
|
+
) : null}
|
|
306
|
+
</box>
|
|
307
|
+
)
|
|
308
|
+
}
|
|
@@ -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 "./atoms.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
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
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 "./atoms.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
|
+
// Hooks must be called unconditionally — keep them above the early
|
|
30
|
+
// returns. The data-dependent values below all tolerate a 0-length
|
|
31
|
+
// data array, so this stays correct in the empty/loading/error cases.
|
|
32
|
+
const scrollRef = useRef<ScrollBoxRenderable | null>(null)
|
|
33
|
+
const safeSelectedIndex = Math.max(0, Math.min(selectedIndex, logsState.data.length - 1))
|
|
34
|
+
useLayoutEffect(() => {
|
|
35
|
+
scrollRef.current?.scrollChildIntoView(`svc-log-${safeSelectedIndex}`)
|
|
36
|
+
}, [safeSelectedIndex])
|
|
37
|
+
|
|
38
|
+
if (logsState.status === "loading" && logsState.data.length === 0) {
|
|
39
|
+
return <PlainLine text="Loading recent service logs..." fg={colors.count} />
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (logsState.status === "error") {
|
|
43
|
+
return <PlainLine text={logsState.error ?? "Could not load logs."} fg={colors.error} />
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (logsState.data.length === 0) {
|
|
47
|
+
return <PlainLine text={`No logs captured yet for service ${serviceName ?? "unknown"}.`} fg={colors.muted} />
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const selectedLog = logsState.data[safeSelectedIndex] ?? null
|
|
51
|
+
const detailWidth = Math.max(16, contentWidth - 2)
|
|
52
|
+
const detailBodyLines = selectedLog ? wrapTextLines(selectedLog.body, detailWidth, 6) : []
|
|
53
|
+
const detailAttributeLines = selectedLog ? relevantLogAttributes(selectedLog).slice(0, 3) : []
|
|
54
|
+
const detailHeight = selectedLog ? 3 + detailBodyLines.length + detailAttributeLines.length : 0
|
|
55
|
+
const listHeight = Math.max(4, bodyLines - detailHeight - 1)
|
|
56
|
+
|
|
57
|
+
return (
|
|
58
|
+
<box flexDirection="column">
|
|
59
|
+
{selectedLog ? (
|
|
60
|
+
<>
|
|
61
|
+
<TextLine>
|
|
62
|
+
<span fg={logSeverityColor(selectedLog.severityText)}>{selectedLog.severityText.toLowerCase()}</span>
|
|
63
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
64
|
+
<span fg={colors.muted}>{formatLogTimestamp(selectedLog.timestamp)}</span>
|
|
65
|
+
<span fg={colors.separator}>{SEPARATOR}</span>
|
|
66
|
+
<span fg={colors.count}>{selectedLog.traceId ? selectedLog.traceId.slice(-8) : "no-trace"}</span>
|
|
67
|
+
</TextLine>
|
|
68
|
+
<TextLine>
|
|
69
|
+
<span fg={colors.defaultService}>{selectedLog.scopeName ?? selectedLog.serviceName}</span>
|
|
70
|
+
{selectedLog.spanId ? <><span fg={colors.separator}>{SEPARATOR}</span><span fg={colors.muted}>{selectedLog.spanId.slice(-8)}</span></> : null}
|
|
71
|
+
</TextLine>
|
|
72
|
+
{detailBodyLines.map((line, index) => (
|
|
73
|
+
<PlainLine key={`log-detail-${selectedLog.id}-${index}`} text={line} fg={colors.text} />
|
|
74
|
+
))}
|
|
75
|
+
{detailAttributeLines.map(([key, value]) => (
|
|
76
|
+
<TextLine key={`log-attr-${selectedLog.id}-${key}`}>
|
|
77
|
+
<span fg={colors.count}>{truncateText(key, 18).padEnd(18, " ")}</span>
|
|
78
|
+
<span fg={colors.muted}> </span>
|
|
79
|
+
<span fg={colors.muted}>{truncateText(value, Math.max(12, detailWidth - 20))}</span>
|
|
80
|
+
</TextLine>
|
|
81
|
+
))}
|
|
82
|
+
<Divider width={contentWidth} />
|
|
83
|
+
</>
|
|
84
|
+
) : null}
|
|
85
|
+
<TextLine fg={colors.muted}>
|
|
86
|
+
<span>{fitCell("time", timeWidth)}</span>
|
|
87
|
+
<span> </span>
|
|
88
|
+
<span>{fitCell("lvl", levelWidth)}</span>
|
|
89
|
+
<span> </span>
|
|
90
|
+
<span>{fitCell("trace", traceWidth)}</span>
|
|
91
|
+
<span> </span>
|
|
92
|
+
<span>{fitCell("message", messageWidth)}</span>
|
|
93
|
+
</TextLine>
|
|
94
|
+
<scrollbox ref={scrollRef} height={listHeight} flexGrow={0}>
|
|
95
|
+
{logsState.data.map((log, index) => {
|
|
96
|
+
const selected = index === safeSelectedIndex
|
|
97
|
+
const trace = log.traceId ? log.traceId.slice(-8) : "-"
|
|
98
|
+
return (
|
|
99
|
+
<box id={`svc-log-${index}`} key={log.id} height={1} onMouseDown={() => onSelectLog(index)}>
|
|
100
|
+
<TextLine fg={selected ? colors.selectedText : colors.text} bg={selected ? colors.selectedBg : undefined}>
|
|
101
|
+
<span fg={colors.muted}>{fitCell(formatTimestamp(log.timestamp), timeWidth)}</span>
|
|
102
|
+
<span> </span>
|
|
103
|
+
<span fg={logSeverityColor(log.severityText)}>{fitCell(log.severityText.toLowerCase(), levelWidth)}</span>
|
|
104
|
+
<span> </span>
|
|
105
|
+
<span fg={colors.count}>{fitCell(trace, traceWidth)}</span>
|
|
106
|
+
<span> </span>
|
|
107
|
+
<span fg={selected ? colors.selectedText : colors.text}>{fitCell(logHeadline(log.body), messageWidth)}</span>
|
|
108
|
+
</TextLine>
|
|
109
|
+
</box>
|
|
110
|
+
)
|
|
111
|
+
})}
|
|
112
|
+
</scrollbox>
|
|
113
|
+
</box>
|
|
114
|
+
)
|
|
115
|
+
}
|