@skyhook-io/k8s-ui 1.5.0 → 1.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.5.0",
3
+ "version": "1.5.2",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -88,7 +88,7 @@
88
88
  "react": "^19.2.4",
89
89
  "react-dom": "^19.2.4",
90
90
  "typescript": "^6.0.2",
91
- "vitest": "^4.1.4",
91
+ "vitest": "^4.1.5",
92
92
  "yaml": "^2.8.3"
93
93
  }
94
94
  }
@@ -96,6 +96,35 @@ export function LocalTerminalTab({
96
96
  setTimeout(doFit, 100)
97
97
  })
98
98
 
99
+ // Attach ResizeObserver synchronously — before createSession() resolves.
100
+ // The dock's expand animation runs concurrently with session creation, so
101
+ // a late-attached observer would miss its size changes and leave the
102
+ // terminal stuck at whatever dimensions it had when the dock was still
103
+ // collapsing. Debounced to coalesce animation frames.
104
+ let resizeTimeout: ReturnType<typeof setTimeout> | null = null
105
+ let lastWidth = 0
106
+ let lastHeight = 0
107
+ const resizeObserver = new ResizeObserver((entries) => {
108
+ const entry = entries[0]
109
+ if (!entry) return
110
+ const { width, height } = entry.contentRect
111
+ if (Math.abs(width - lastWidth) < 5 && Math.abs(height - lastHeight) < 5) return
112
+ lastWidth = width
113
+ lastHeight = height
114
+ if (resizeTimeout) clearTimeout(resizeTimeout)
115
+ resizeTimeout = setTimeout(() => {
116
+ if (!fitAddonRef.current || !xtermRef.current) return
117
+ const dims = fitAddonRef.current.proposeDimensions()
118
+ if (dims) xtermRef.current.resize(dims.cols, dims.rows)
119
+ const conn = wsRef.current
120
+ if (conn?.readyState === WebSocket.OPEN) {
121
+ conn.send(JSON.stringify({ type: 'resize', rows: xtermRef.current.rows, cols: xtermRef.current.cols }))
122
+ }
123
+ }, 100)
124
+ })
125
+ resizeObserver.observe(terminalRef.current)
126
+ cleanupRef.current = () => resizeObserver.disconnect()
127
+
99
128
  createSessionRef.current()
100
129
  .then(({ wsUrl }) => {
101
130
  if (cancelledRef.current) return
@@ -152,35 +181,6 @@ export function LocalTerminalTab({
152
181
  ws.send(JSON.stringify({ type: 'input', data }))
153
182
  }
154
183
  })
155
-
156
- // Debounced resize
157
- let resizeTimeout: ReturnType<typeof setTimeout> | null = null
158
- let lastWidth = 0
159
- let lastHeight = 0
160
- const resizeObserver = new ResizeObserver((entries) => {
161
- const entry = entries[0]
162
- if (!entry) return
163
- const { width, height } = entry.contentRect
164
- if (Math.abs(width - lastWidth) < 5 && Math.abs(height - lastHeight) < 5) return
165
- lastWidth = width
166
- lastHeight = height
167
- if (resizeTimeout) clearTimeout(resizeTimeout)
168
- resizeTimeout = setTimeout(() => {
169
- if (fitAddonRef.current && xtermRef.current) {
170
- const dims = fitAddonRef.current.proposeDimensions()
171
- if (dims) xtermRef.current.resize(dims.cols, dims.rows)
172
- if (ws.readyState === WebSocket.OPEN) {
173
- ws.send(JSON.stringify({ type: 'resize', rows: xtermRef.current.rows, cols: xtermRef.current.cols }))
174
- }
175
- }
176
- }, 100)
177
- })
178
- if (terminalRef.current) {
179
- resizeObserver.observe(terminalRef.current)
180
- cleanupRef.current = () => resizeObserver.disconnect()
181
- } else {
182
- resizeObserver.disconnect()
183
- }
184
184
  })
185
185
  .catch((err) => {
186
186
  setError(err instanceof Error ? err.message : 'Failed to connect')
@@ -113,6 +113,35 @@ export function TerminalTab({
113
113
  setTimeout(doFit, 100)
114
114
  })
115
115
 
116
+ // Attach ResizeObserver synchronously — before createSession() resolves.
117
+ // The dock's expand animation runs concurrently with session creation, so
118
+ // a late-attached observer would miss its size changes and leave the
119
+ // terminal stuck at whatever dimensions it had when the dock was still
120
+ // collapsing. Debounced to coalesce animation frames.
121
+ let resizeTimeout: ReturnType<typeof setTimeout> | null = null
122
+ let lastWidth = 0
123
+ let lastHeight = 0
124
+ const resizeObserver = new ResizeObserver((entries) => {
125
+ const entry = entries[0]
126
+ if (!entry) return
127
+ const { width, height } = entry.contentRect
128
+ if (Math.abs(width - lastWidth) < 5 && Math.abs(height - lastHeight) < 5) return
129
+ lastWidth = width
130
+ lastHeight = height
131
+ if (resizeTimeout) clearTimeout(resizeTimeout)
132
+ resizeTimeout = setTimeout(() => {
133
+ if (!fitAddonRef.current || !xtermRef.current) return
134
+ const dims = fitAddonRef.current.proposeDimensions()
135
+ if (dims) xtermRef.current.resize(dims.cols, dims.rows)
136
+ const conn = wsRef.current
137
+ if (conn?.readyState === WebSocket.OPEN) {
138
+ conn.send(JSON.stringify({ type: 'resize', rows: xtermRef.current.rows, cols: xtermRef.current.cols }))
139
+ }
140
+ }, 100)
141
+ })
142
+ resizeObserver.observe(terminalRef.current)
143
+ cleanupRef.current = () => resizeObserver.disconnect()
144
+
116
145
  createSessionRef.current(selectedContainer)
117
146
  .then(({ wsUrl }) => {
118
147
  if (cancelledRef.current) return
@@ -163,35 +192,6 @@ export function TerminalTab({
163
192
  ws.send(JSON.stringify({ type: 'input', data }))
164
193
  }
165
194
  })
166
-
167
- // Debounced resize to avoid infinite loops
168
- let resizeTimeout: ReturnType<typeof setTimeout> | null = null
169
- let lastWidth = 0
170
- let lastHeight = 0
171
- const resizeObserver = new ResizeObserver((entries) => {
172
- const entry = entries[0]
173
- if (!entry) return
174
- const { width, height } = entry.contentRect
175
- if (Math.abs(width - lastWidth) < 5 && Math.abs(height - lastHeight) < 5) return
176
- lastWidth = width
177
- lastHeight = height
178
- if (resizeTimeout) clearTimeout(resizeTimeout)
179
- resizeTimeout = setTimeout(() => {
180
- if (fitAddonRef.current && xtermRef.current) {
181
- const dims = fitAddonRef.current.proposeDimensions()
182
- if (dims) xtermRef.current.resize(dims.cols, dims.rows)
183
- if (ws.readyState === WebSocket.OPEN) {
184
- ws.send(JSON.stringify({ type: 'resize', rows: xtermRef.current.rows, cols: xtermRef.current.cols }))
185
- }
186
- }
187
- }, 100)
188
- })
189
- if (terminalRef.current) {
190
- resizeObserver.observe(terminalRef.current)
191
- cleanupRef.current = () => resizeObserver.disconnect()
192
- } else {
193
- resizeObserver.disconnect()
194
- }
195
195
  })
196
196
  .catch((err) => {
197
197
  setError(err instanceof Error ? err.message : 'Failed to connect')
@@ -1,6 +1,6 @@
1
1
  import { useRef, useCallback, useState, useMemo, useEffect, type ReactNode } from 'react'
2
2
  import { Virtuoso, type VirtuosoHandle } from 'react-virtuoso'
3
- import { Play, Square, Download, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, Braces, Sun, Moon } from 'lucide-react'
3
+ import { Play, Square, Download, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, ChevronRight, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, Braces, Palette, ListCollapse } from 'lucide-react'
4
4
  import type { LogEntry, LogLevel } from './useLogBuffer'
5
5
  import { useLogSearch } from './useLogSearch'
6
6
  import { StructuredLogLine } from './StructuredLogLine'
@@ -11,6 +11,8 @@ import {
11
11
  highlightSearchMatches,
12
12
  stripAnsi,
13
13
  ansiToHtml,
14
+ type TimestampFormat,
15
+ TIMESTAMP_FORMAT_LABELS,
14
16
  } from '../../utils/log-format'
15
17
 
16
18
  export type DownloadFormat = 'txt' | 'json' | 'csv'
@@ -28,7 +30,7 @@ interface LogCoreProps {
28
30
  showPodName?: boolean
29
31
  emptyMessage?: string
30
32
  errorMessage?: string | null
31
- /** Force dark mode on the logs container (default: true). Logs are typically dark-themed. */
33
+ /** Optionally force dark styling for embedded consumers. */
32
34
  forceDark?: boolean
33
35
  }
34
36
 
@@ -39,6 +41,32 @@ const LEVEL_OPTIONS: { level: LogLevel; label: string; color: string; activeColo
39
41
  { level: 'debug', label: 'DBG', color: 'text-theme-text-secondary', activeColor: 'bg-theme-surface text-theme-text-secondary border-theme-border-light' },
40
42
  ]
41
43
 
44
+ const TIMESTAMP_FORMAT_ORDER: TimestampFormat[] = [
45
+ 'time-local', 'time-utc', 'iso-local', 'iso-utc', 'relative', 'epoch',
46
+ ]
47
+
48
+ const TIMESTAMP_FORMAT_SHORT_LABELS: Record<TimestampFormat, string> = {
49
+ 'time-local': 'Local time',
50
+ 'time-utc': 'UTC time',
51
+ 'iso-local': 'Full date',
52
+ 'iso-utc': 'UTC date',
53
+ 'relative': 'Relative',
54
+ 'epoch': 'Unix time',
55
+ }
56
+
57
+ function isContinuationLine(content: string): boolean {
58
+ // Lines starting with whitespace are the dominant stack-trace continuation pattern:
59
+ // Java `\tat com.foo.Bar`, Go `\tpackage.func`, Node ` at func`, Python ` File "..."`.
60
+ if (/^\s/.test(content)) return true
61
+ // Java's secondary chain markers that don't start with whitespace.
62
+ return /^(Caused by:|Suppressed:|\.\.\. \d+ more)/.test(content)
63
+ }
64
+
65
+ interface LogGroup {
66
+ head: LogEntry
67
+ continuations: LogEntry[]
68
+ }
69
+
42
70
  const TIP_DELAY = 150
43
71
 
44
72
  export function LogCore({
@@ -54,24 +82,43 @@ export function LogCore({
54
82
  showPodName = false,
55
83
  emptyMessage = 'No logs available',
56
84
  errorMessage,
57
- forceDark = true,
85
+ forceDark = false,
58
86
  }: LogCoreProps) {
59
87
  const virtuosoRef = useRef<VirtuosoHandle>(null)
60
88
  const [atBottom, setAtBottom] = useState(true)
61
- const [isDark, setIsDark] = useState(() => {
62
- try { const v = localStorage.getItem('radar-logs-dark'); return v !== null ? v !== 'false' : forceDark } catch { return forceDark }
63
- })
64
89
  const [wordWrap, setWordWrap] = useState(() => {
65
90
  try { return localStorage.getItem('radar-logs-wrap') !== 'false' } catch { return true }
66
91
  })
67
92
  const [showTimestamps, setShowTimestamps] = useState(() => {
68
93
  try { return localStorage.getItem('radar-logs-timestamps') !== 'false' } catch { return true }
69
94
  })
95
+ const [tsFormat, setTsFormat] = useState<TimestampFormat>(() => {
96
+ try {
97
+ const v = localStorage.getItem('radar-logs-ts-format') as TimestampFormat | null
98
+ return v && TIMESTAMP_FORMAT_ORDER.includes(v) ? v : 'time-local'
99
+ } catch { return 'time-local' }
100
+ })
101
+ const [ansiEnabled, setAnsiEnabled] = useState(() => {
102
+ try { return localStorage.getItem('radar-logs-ansi') !== 'false' } catch { return true }
103
+ })
104
+ const [collapseStacks, setCollapseStacks] = useState(() => {
105
+ try { return localStorage.getItem('radar-logs-collapse-stacks') !== 'false' } catch { return true }
106
+ })
70
107
  const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
71
108
  new Set(['error', 'warn', 'info', 'debug'])
72
109
  )
73
110
  const [showDownloadMenu, setShowDownloadMenu] = useState(false)
111
+ const [showTsMenu, setShowTsMenu] = useState(false)
74
112
  const [expandAllStructured, setExpandAllStructured] = useState(false)
113
+ const [expandedStacks, setExpandedStacks] = useState<Set<number>>(() => new Set())
114
+
115
+ // Re-render every 15s so "relative" timestamps tick forward during idle viewing.
116
+ const [, setNowTick] = useState(0)
117
+ useEffect(() => {
118
+ if (tsFormat !== 'relative' || !showTimestamps) return
119
+ const id = setInterval(() => setNowTick(n => n + 1), 15_000)
120
+ return () => clearInterval(id)
121
+ }, [tsFormat, showTimestamps])
75
122
 
76
123
  // Level-filtered entries
77
124
  // 'unknown' logs are shown when all 4 known levels are enabled (no active filtering)
@@ -112,6 +159,18 @@ export function LogCore({
112
159
  return () => window.removeEventListener('click', handleClick)
113
160
  }, [showDownloadMenu])
114
161
 
162
+ // Same close-on-outside-click for the timestamp format menu.
163
+ const tsMenuRef = useRef<HTMLDivElement>(null)
164
+ useEffect(() => {
165
+ if (!showTsMenu) return
166
+ const handleClick = (e: MouseEvent) => {
167
+ if (tsMenuRef.current?.contains(e.target as Node)) return
168
+ setShowTsMenu(false)
169
+ }
170
+ window.addEventListener('click', handleClick)
171
+ return () => window.removeEventListener('click', handleClick)
172
+ }, [showTsMenu])
173
+
115
174
  // Keyboard shortcut: Ctrl+F to open search
116
175
  useEffect(() => {
117
176
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -133,14 +192,6 @@ export function LogCore({
133
192
  setAtBottom(bottom)
134
193
  }, [])
135
194
 
136
- const scrollToBottom = useCallback(() => {
137
- virtuosoRef.current?.scrollToIndex({
138
- index: displayEntries.length - 1,
139
- align: 'end',
140
- behavior: 'smooth',
141
- })
142
- }, [displayEntries.length])
143
-
144
195
  const toggleWrap = useCallback(() => {
145
196
  setWordWrap(prev => {
146
197
  const next = !prev
@@ -157,6 +208,50 @@ export function LogCore({
157
208
  })
158
209
  }, [])
159
210
 
211
+ const pickTsFormat = useCallback((fmt: TimestampFormat) => {
212
+ setTsFormat(fmt)
213
+ try { localStorage.setItem('radar-logs-ts-format', fmt) } catch {}
214
+ // Auto-show timestamps when user picks a format — otherwise the change isn't visible.
215
+ setShowTimestamps(true)
216
+ try { localStorage.setItem('radar-logs-timestamps', 'true') } catch {}
217
+ setShowTsMenu(false)
218
+ }, [])
219
+
220
+ const toggleAnsi = useCallback(() => {
221
+ setAnsiEnabled(prev => {
222
+ const next = !prev
223
+ try { localStorage.setItem('radar-logs-ansi', String(next)) } catch {}
224
+ return next
225
+ })
226
+ }, [])
227
+
228
+ const toggleCollapseStacks = useCallback(() => {
229
+ setCollapseStacks(prev => {
230
+ const next = !prev
231
+ try { localStorage.setItem('radar-logs-collapse-stacks', String(next)) } catch {}
232
+ return next
233
+ })
234
+ }, [])
235
+
236
+ const toggleStackExpanded = useCallback((id: number) => {
237
+ setExpandedStacks(prev => {
238
+ const next = new Set(prev)
239
+ if (next.has(id)) next.delete(id)
240
+ else next.add(id)
241
+ return next
242
+ })
243
+ }, [])
244
+
245
+ // Clicking a filter chip in a structured log value pushes the value into the
246
+ // log search and enables filter mode so only matching lines are shown.
247
+ const handleFilterValue = useCallback((value: string) => {
248
+ search.setQuery(value)
249
+ // Values often contain regex metacharacters — force literal-substring matching.
250
+ search.setIsRegex(false)
251
+ search.setFilterMode(true)
252
+ if (!search.isOpen) search.open()
253
+ }, [search])
254
+
160
255
  const toggleLevel = useCallback((level: LogLevel) => {
161
256
  setEnabledLevels(prev => {
162
257
  const next = new Set(prev)
@@ -176,8 +271,36 @@ export function LogCore({
176
271
  : levelFilteredEntries[search.matchIndices[search.currentMatch]]?.id)
177
272
  : -1
178
273
 
274
+ // Group stack-trace continuation lines under their preceding head line.
275
+ // Disabled while search is active so matches inside continuations remain visible.
276
+ const groupedEntries = useMemo<LogGroup[]>(() => {
277
+ if (!collapseStacks || search.query) {
278
+ return displayEntries.map(e => ({ head: e, continuations: [] }))
279
+ }
280
+ const groups: LogGroup[] = []
281
+ for (const entry of displayEntries) {
282
+ if (groups.length > 0 && isContinuationLine(entry.content)) {
283
+ groups[groups.length - 1].continuations.push(entry)
284
+ } else {
285
+ groups.push({ head: entry, continuations: [] })
286
+ }
287
+ }
288
+ return groups
289
+ }, [displayEntries, collapseStacks, search.query])
290
+
291
+ const scrollToBottom = useCallback(() => {
292
+ virtuosoRef.current?.scrollToIndex({
293
+ index: groupedEntries.length - 1,
294
+ align: 'end',
295
+ behavior: 'smooth',
296
+ })
297
+ }, [groupedEntries.length])
298
+
179
299
  return (
180
- <div className={`flex flex-col h-full bg-theme-base${isDark ? ' dark' : ''}`} style={{ colorScheme: isDark ? 'dark' : 'light', fontFamily: "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" }}>
300
+ <div
301
+ className={`flex flex-col h-full bg-theme-base${forceDark ? ' dark' : ''}`}
302
+ style={{ colorScheme: forceDark ? 'dark' : undefined, fontFamily: "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" }}
303
+ >
181
304
  {/* Toolbar */}
182
305
  <div className="flex items-center gap-2 px-3 py-2 border-b border-theme-border bg-theme-surface">
183
306
  {toolbarExtra}
@@ -248,15 +371,76 @@ export function LogCore({
248
371
  </Tooltip>
249
372
  )}
250
373
 
251
- {/* Timestamp toggle */}
252
- <Tooltip content={showTimestamps ? 'Hide timestamps' : 'Show timestamps'} delay={TIP_DELAY} position="bottom">
374
+ {/* Timestamp toggle + format picker */}
375
+ <div className="flex items-center">
376
+ <Tooltip content={showTimestamps ? 'Hide timestamps' : 'Show timestamps'} delay={TIP_DELAY} position="bottom">
377
+ <button
378
+ onClick={toggleTimestamps}
379
+ className={`p-1.5 rounded-l transition-colors ${
380
+ showTimestamps ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
381
+ }`}
382
+ >
383
+ <Clock className="w-4 h-4" />
384
+ </button>
385
+ </Tooltip>
386
+ <div className="relative" ref={tsMenuRef}>
387
+ <Tooltip content={`Timestamp format: ${TIMESTAMP_FORMAT_LABELS[tsFormat]}`} delay={TIP_DELAY} position="bottom">
388
+ <button
389
+ onClick={() => setShowTsMenu(prev => !prev)}
390
+ className={`px-2 py-1.5 rounded-r text-[10px] font-medium transition-colors whitespace-nowrap ${
391
+ showTimestamps ? 'btn-brand-toggle' : 'text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-elevated'
392
+ }`}
393
+ aria-label="Pick timestamp format"
394
+ >
395
+ <span className="inline-flex items-center gap-1">
396
+ <span>{TIMESTAMP_FORMAT_SHORT_LABELS[tsFormat]}</span>
397
+ <ChevronDown className="w-3 h-3" />
398
+ </span>
399
+ </button>
400
+ </Tooltip>
401
+ {showTsMenu && (
402
+ <div className="absolute top-full right-0 mt-1 w-44 bg-theme-elevated border border-theme-border rounded-lg shadow-lg z-50">
403
+ <div className="px-3 py-1.5 text-[10px] uppercase tracking-wide text-theme-text-tertiary border-b border-theme-border">
404
+ Timestamp format
405
+ </div>
406
+ {TIMESTAMP_FORMAT_ORDER.map(fmt => (
407
+ <button
408
+ key={fmt}
409
+ onClick={() => pickTsFormat(fmt)}
410
+ className={`w-full text-left px-3 py-1.5 text-xs hover:bg-theme-hover flex items-center justify-between ${
411
+ tsFormat === fmt ? 'text-theme-text-primary' : 'text-theme-text-secondary'
412
+ }`}
413
+ >
414
+ <span>{TIMESTAMP_FORMAT_LABELS[fmt]}</span>
415
+ {tsFormat === fmt && <span className="text-[10px] text-blue-400">✓</span>}
416
+ </button>
417
+ ))}
418
+ </div>
419
+ )}
420
+ </div>
421
+ </div>
422
+
423
+ {/* Collapse stack-trace continuations toggle */}
424
+ <Tooltip content={collapseStacks ? 'Stop grouping stack traces' : 'Group stack-trace lines'} delay={TIP_DELAY} position="bottom">
425
+ <button
426
+ onClick={toggleCollapseStacks}
427
+ className={`p-1.5 rounded transition-colors ${
428
+ collapseStacks ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
429
+ }`}
430
+ >
431
+ <ListCollapse className="w-4 h-4" />
432
+ </button>
433
+ </Tooltip>
434
+
435
+ {/* ANSI color rendering toggle */}
436
+ <Tooltip content={ansiEnabled ? 'Hide ANSI colors' : 'Render ANSI colors'} delay={TIP_DELAY} position="bottom">
253
437
  <button
254
- onClick={toggleTimestamps}
438
+ onClick={toggleAnsi}
255
439
  className={`p-1.5 rounded transition-colors ${
256
- showTimestamps ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
440
+ ansiEnabled ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
257
441
  }`}
258
442
  >
259
- <Clock className="w-4 h-4" />
443
+ <Palette className="w-4 h-4" />
260
444
  </button>
261
445
  </Tooltip>
262
446
 
@@ -309,20 +493,6 @@ export function LogCore({
309
493
  )}
310
494
  </div>
311
495
 
312
- {/* Dark/Light toggle */}
313
- <Tooltip content={isDark ? 'Light mode' : 'Dark mode'} delay={TIP_DELAY} position="bottom">
314
- <button
315
- onClick={() => {
316
- const next = !isDark
317
- setIsDark(next)
318
- try { localStorage.setItem('radar-logs-dark', String(next)) } catch {}
319
- }}
320
- className="p-1.5 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated"
321
- >
322
- {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
323
- </button>
324
- </Tooltip>
325
-
326
496
  {/* Clear */}
327
497
  {onClear && (
328
498
  <Tooltip content="Clear logs" delay={TIP_DELAY} position="bottom">
@@ -450,7 +620,7 @@ export function LogCore({
450
620
  <Terminal className="w-8 h-8" />
451
621
  <span>{errorMessage}</span>
452
622
  </div>
453
- ) : displayEntries.length === 0 ? (
623
+ ) : groupedEntries.length === 0 ? (
454
624
  <div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-2">
455
625
  <Terminal className="w-8 h-8" />
456
626
  <span>{emptyMessage}</span>
@@ -459,23 +629,28 @@ export function LogCore({
459
629
  <div className="flex-1 relative">
460
630
  <Virtuoso
461
631
  ref={virtuosoRef}
462
- data={displayEntries}
632
+ data={groupedEntries}
463
633
  followOutput={handleFollowOutput}
464
- initialTopMostItemIndex={displayEntries.length - 1}
634
+ initialTopMostItemIndex={groupedEntries.length - 1}
465
635
  atBottomStateChange={handleAtBottomStateChange}
466
636
  atBottomThreshold={50}
467
637
  increaseViewportBy={200}
468
- itemContent={(_index, entry) => (
469
- <LogLine
470
- entry={entry}
638
+ itemContent={(_index, group) => (
639
+ <LogGroupItem
640
+ group={group}
471
641
  searchQuery={search.query}
472
642
  searchIsRegex={search.isRegex}
473
643
  searchIsCaseSensitive={search.isCaseSensitive}
474
644
  showPodName={showPodName}
475
645
  showTimestamp={showTimestamps}
476
- isCurrentMatch={entry.id === currentHighlightId}
646
+ tsFormat={tsFormat}
647
+ ansiEnabled={ansiEnabled}
648
+ isCurrentMatch={group.head.id === currentHighlightId}
477
649
  wordWrap={wordWrap}
478
650
  defaultExpanded={expandAllStructured}
651
+ onFilterValue={handleFilterValue}
652
+ isStackExpanded={expandedStacks.has(group.head.id)}
653
+ onToggleStack={toggleStackExpanded}
479
654
  />
480
655
  )}
481
656
  className="h-full font-mono text-xs"
@@ -512,6 +687,23 @@ function Shortcut({ keys, label }: { keys: string; label: string }) {
512
687
  )
513
688
  }
514
689
 
690
+ interface LogLineProps {
691
+ entry: LogEntry
692
+ searchQuery: string
693
+ searchIsRegex: boolean
694
+ searchIsCaseSensitive: boolean
695
+ showPodName: boolean
696
+ showTimestamp: boolean
697
+ tsFormat: TimestampFormat
698
+ ansiEnabled: boolean
699
+ isCurrentMatch: boolean
700
+ wordWrap: boolean
701
+ defaultExpanded: boolean
702
+ onFilterValue?: (value: string) => void
703
+ /** Optional lead element rendered at the start of the row (e.g. stack-trace toggle). */
704
+ leadSlot?: ReactNode
705
+ }
706
+
515
707
  function LogLine({
516
708
  entry,
517
709
  searchQuery,
@@ -519,23 +711,17 @@ function LogLine({
519
711
  searchIsCaseSensitive,
520
712
  showPodName,
521
713
  showTimestamp,
714
+ tsFormat,
715
+ ansiEnabled,
522
716
  isCurrentMatch,
523
717
  wordWrap,
524
718
  defaultExpanded,
525
- }: {
526
- entry: LogEntry
527
- searchQuery: string
528
- searchIsRegex: boolean
529
- searchIsCaseSensitive: boolean
530
- showPodName: boolean
531
- showTimestamp: boolean
532
- isCurrentMatch: boolean
533
- wordWrap: boolean
534
- defaultExpanded: boolean
535
- }) {
719
+ onFilterValue,
720
+ leadSlot,
721
+ }: LogLineProps) {
536
722
  const levelColor = getLevelColor(entry.level)
537
723
 
538
- // Determine content rendering
724
+ // Determine content rendering. Priority: search highlight > structured > ANSI/plain.
539
725
  let contentElement: React.ReactNode
540
726
  if (searchQuery) {
541
727
  const plain = stripAnsi(entry.content)
@@ -554,9 +740,10 @@ function LogLine({
554
740
  wordWrap={wordWrap}
555
741
  isLogfmt={entry.isLogfmt}
556
742
  defaultExpanded={defaultExpanded}
743
+ onFilterValue={onFilterValue}
557
744
  />
558
745
  )
559
- } else {
746
+ } else if (ansiEnabled) {
560
747
  const html = ansiToHtml(entry.content)
561
748
  contentElement = (
562
749
  <span
@@ -564,6 +751,12 @@ function LogLine({
564
751
  dangerouslySetInnerHTML={{ __html: html }}
565
752
  />
566
753
  )
754
+ } else {
755
+ contentElement = (
756
+ <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}>
757
+ {stripAnsi(entry.content)}
758
+ </span>
759
+ )
567
760
  }
568
761
 
569
762
  const handleCopy = () => {
@@ -573,9 +766,13 @@ function LogLine({
573
766
 
574
767
  return (
575
768
  <div className={`flex hover:bg-theme-surface/50 group leading-5 px-2 ${isCurrentMatch ? 'bg-yellow-500/10' : ''}`}>
769
+ {leadSlot}
576
770
  {showTimestamp && entry.timestamp && (
577
- <span className="text-theme-text-tertiary select-none pr-2 whitespace-nowrap">
578
- {formatLogTimestamp(entry.timestamp)}
771
+ <span
772
+ className="text-theme-text-tertiary select-none pr-2 whitespace-nowrap"
773
+ title={entry.timestamp}
774
+ >
775
+ {formatLogTimestamp(entry.timestamp, tsFormat)}
579
776
  </span>
580
777
  )}
581
778
  {showPodName && entry.pod && (
@@ -597,3 +794,60 @@ function LogLine({
597
794
  </div>
598
795
  )
599
796
  }
797
+
798
+ interface LogGroupItemProps {
799
+ group: LogGroup
800
+ searchQuery: string
801
+ searchIsRegex: boolean
802
+ searchIsCaseSensitive: boolean
803
+ showPodName: boolean
804
+ showTimestamp: boolean
805
+ tsFormat: TimestampFormat
806
+ ansiEnabled: boolean
807
+ isCurrentMatch: boolean
808
+ wordWrap: boolean
809
+ defaultExpanded: boolean
810
+ onFilterValue: (value: string) => void
811
+ isStackExpanded: boolean
812
+ onToggleStack: (id: number) => void
813
+ }
814
+
815
+ function LogGroupItem(props: LogGroupItemProps) {
816
+ const { group, isStackExpanded, onToggleStack, ...rest } = props
817
+ const hasStack = group.continuations.length > 0
818
+
819
+ const stackToggle = hasStack ? (
820
+ <button
821
+ onClick={() => onToggleStack(group.head.id)}
822
+ className="mr-1 self-start p-0.5 rounded text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-surface/50 shrink-0"
823
+ title={isStackExpanded ? 'Collapse stack trace' : `Expand ${group.continuations.length} stack frames`}
824
+ >
825
+ {isStackExpanded
826
+ ? <ChevronDown className="w-3 h-3" />
827
+ : <ChevronRight className="w-3 h-3" />}
828
+ </button>
829
+ ) : null
830
+
831
+ return (
832
+ <div>
833
+ <LogLine
834
+ entry={group.head}
835
+ {...rest}
836
+ leadSlot={stackToggle}
837
+ />
838
+ {hasStack && !isStackExpanded && (
839
+ <button
840
+ onClick={() => onToggleStack(group.head.id)}
841
+ className="block w-full text-left pl-6 pr-2 py-0 text-[10px] text-theme-text-tertiary hover:text-theme-text-primary hover:bg-theme-surface/40"
842
+ >
843
+ [+{group.continuations.length} stack {group.continuations.length === 1 ? 'line' : 'lines'}]
844
+ </button>
845
+ )}
846
+ {hasStack && isStackExpanded && group.continuations.map(cont => (
847
+ <div key={cont.id} className="pl-4">
848
+ <LogLine entry={cont} {...rest} isCurrentMatch={false} />
849
+ </div>
850
+ ))}
851
+ </div>
852
+ )
853
+ }