@skyhook-io/k8s-ui 1.5.1 → 1.5.3

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.
@@ -1,20 +1,30 @@
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, Sun, Moon } from 'lucide-react'
4
4
  import type { LogEntry, LogLevel } from './useLogBuffer'
5
5
  import { useLogSearch } from './useLogSearch'
6
6
  import { StructuredLogLine } from './StructuredLogLine'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
8
  import {
9
9
  formatLogTimestamp,
10
- getLevelColor,
11
10
  highlightSearchMatches,
12
11
  stripAnsi,
13
12
  ansiToHtml,
13
+ type TimestampFormat,
14
+ TIMESTAMP_FORMAT_LABELS,
14
15
  } from '../../utils/log-format'
16
+ import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
15
17
 
16
18
  export type DownloadFormat = 'txt' | 'json' | 'csv'
17
19
 
20
+ /**
21
+ * `toolbarExtra` may be a plain ReactNode, or a function that receives the
22
+ * current dark/light context so wrappers can produce palette-matched controls.
23
+ */
24
+ export type ToolbarExtraRenderer =
25
+ | ReactNode
26
+ | ((ctx: { isDark: boolean; palette: LogPalette }) => ReactNode)
27
+
18
28
  interface LogCoreProps {
19
29
  entries: LogEntry[]
20
30
  isLoading: boolean
@@ -24,21 +34,66 @@ interface LogCoreProps {
24
34
  onRefresh: () => void
25
35
  onDownload: (format: DownloadFormat) => void
26
36
  onClear?: () => void
27
- toolbarExtra?: ReactNode
37
+ toolbarExtra?: ToolbarExtraRenderer
28
38
  showPodName?: boolean
29
39
  emptyMessage?: string
30
40
  errorMessage?: string | null
31
- /** Force dark mode on the logs container (default: true). Logs are typically dark-themed. */
41
+ /**
42
+ * Hard override for the viewer palette. When set, the viewer stays pinned to
43
+ * that mode and hides the in-viewer dark/light toggle. When undefined,
44
+ * the viewer manages its own palette via localStorage and the Sun/Moon button.
45
+ */
32
46
  forceDark?: boolean
33
47
  }
34
48
 
35
- const LEVEL_OPTIONS: { level: LogLevel; label: string; color: string; activeColor: string }[] = [
36
- { level: 'error', label: 'ERR', color: 'text-red-400', activeColor: 'bg-red-500/30 dark:bg-red-500/20 text-red-600 dark:text-red-400 border-red-500/60 dark:border-red-500/40' },
37
- { level: 'warn', label: 'WARN', color: 'text-yellow-400', activeColor: 'bg-yellow-400/30 dark:bg-yellow-500/20 text-yellow-700 dark:text-yellow-400 border-yellow-500/60 dark:border-yellow-500/40' },
38
- { level: 'info', label: 'INFO', color: 'text-blue-400', activeColor: 'bg-blue-500/25 dark:bg-blue-500/20 text-blue-700 dark:text-blue-400 border-blue-500/60 dark:border-blue-500/40' },
39
- { level: 'debug', label: 'DBG', color: 'text-theme-text-secondary', activeColor: 'bg-theme-surface text-theme-text-secondary border-theme-border-light' },
49
+ interface LevelOption {
50
+ level: LogLevel
51
+ label: string
52
+ }
53
+
54
+ const LEVEL_OPTIONS: LevelOption[] = [
55
+ { level: 'error', label: 'ERR' },
56
+ { level: 'warn', label: 'WARN' },
57
+ { level: 'info', label: 'INFO' },
58
+ { level: 'debug', label: 'DBG' },
40
59
  ]
41
60
 
61
+ function getLevelActiveColor(level: LogLevel, palette: LogPalette): string {
62
+ switch (level) {
63
+ case 'error': return palette.levelActiveError
64
+ case 'warn': return palette.levelActiveWarn
65
+ case 'info': return palette.levelActiveInfo
66
+ case 'debug': return palette.levelActiveDebug
67
+ default: return palette.levelActiveDebug
68
+ }
69
+ }
70
+
71
+ const TIMESTAMP_FORMAT_ORDER: TimestampFormat[] = [
72
+ 'time-local', 'time-utc', 'iso-local', 'iso-utc', 'relative', 'epoch',
73
+ ]
74
+
75
+ const TIMESTAMP_FORMAT_SHORT_LABELS: Record<TimestampFormat, string> = {
76
+ 'time-local': 'Local time',
77
+ 'time-utc': 'UTC time',
78
+ 'iso-local': 'Full date',
79
+ 'iso-utc': 'UTC date',
80
+ 'relative': 'Relative',
81
+ 'epoch': 'Unix time',
82
+ }
83
+
84
+ function isContinuationLine(content: string): boolean {
85
+ // Lines starting with whitespace are the dominant stack-trace continuation pattern:
86
+ // Java `\tat com.foo.Bar`, Go `\tpackage.func`, Node ` at func`, Python ` File "..."`.
87
+ if (/^\s/.test(content)) return true
88
+ // Java's secondary chain markers that don't start with whitespace.
89
+ return /^(Caused by:|Suppressed:|\.\.\. \d+ more)/.test(content)
90
+ }
91
+
92
+ interface LogGroup {
93
+ head: LogEntry
94
+ continuations: LogEntry[]
95
+ }
96
+
42
97
  const TIP_DELAY = 150
43
98
 
44
99
  export function LogCore({
@@ -54,24 +109,70 @@ export function LogCore({
54
109
  showPodName = false,
55
110
  emptyMessage = 'No logs available',
56
111
  errorMessage,
57
- forceDark = true,
112
+ forceDark,
58
113
  }: LogCoreProps) {
59
114
  const virtuosoRef = useRef<VirtuosoHandle>(null)
60
115
  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 }
116
+ const themeLocked = typeof forceDark === 'boolean'
117
+ // Seed isDark: forceDark prop wins; else localStorage['radar-logs-dark'];
118
+ // else default dark. See log-palette.ts for why the viewer is palette-driven
119
+ // instead of theme-token-driven.
120
+ const [isDark, setIsDark] = useState<boolean>(() => {
121
+ if (typeof forceDark === 'boolean') return forceDark
122
+ try {
123
+ const v = localStorage.getItem('radar-logs-dark')
124
+ if (v === 'false') return false
125
+ if (v === 'true') return true
126
+ } catch {}
127
+ return true
63
128
  })
129
+ useEffect(() => {
130
+ if (typeof forceDark === 'boolean') {
131
+ setIsDark(forceDark)
132
+ }
133
+ }, [forceDark])
134
+ const palette = useMemo(() => getLogPalette(isDark), [isDark])
135
+ const toggleDark = useCallback(() => {
136
+ if (themeLocked) return
137
+ setIsDark(prev => {
138
+ const next = !prev
139
+ try { localStorage.setItem('radar-logs-dark', String(next)) } catch {}
140
+ return next
141
+ })
142
+ }, [themeLocked])
64
143
  const [wordWrap, setWordWrap] = useState(() => {
65
144
  try { return localStorage.getItem('radar-logs-wrap') !== 'false' } catch { return true }
66
145
  })
67
146
  const [showTimestamps, setShowTimestamps] = useState(() => {
68
147
  try { return localStorage.getItem('radar-logs-timestamps') !== 'false' } catch { return true }
69
148
  })
149
+ const [tsFormat, setTsFormat] = useState<TimestampFormat>(() => {
150
+ try {
151
+ const v = localStorage.getItem('radar-logs-ts-format') as TimestampFormat | null
152
+ return v && TIMESTAMP_FORMAT_ORDER.includes(v) ? v : 'time-local'
153
+ } catch { return 'time-local' }
154
+ })
155
+ const [ansiEnabled, setAnsiEnabled] = useState(() => {
156
+ try { return localStorage.getItem('radar-logs-ansi') !== 'false' } catch { return true }
157
+ })
158
+ const [collapseStacks, setCollapseStacks] = useState(() => {
159
+ try { return localStorage.getItem('radar-logs-collapse-stacks') !== 'false' } catch { return true }
160
+ })
70
161
  const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
71
162
  new Set(['error', 'warn', 'info', 'debug'])
72
163
  )
73
164
  const [showDownloadMenu, setShowDownloadMenu] = useState(false)
165
+ const [showTsMenu, setShowTsMenu] = useState(false)
74
166
  const [expandAllStructured, setExpandAllStructured] = useState(false)
167
+ const [expandedStacks, setExpandedStacks] = useState<Set<number>>(() => new Set())
168
+
169
+ // Re-render every 15s so "relative" timestamps tick forward during idle viewing.
170
+ const [, setNowTick] = useState(0)
171
+ useEffect(() => {
172
+ if (tsFormat !== 'relative' || !showTimestamps) return
173
+ const id = setInterval(() => setNowTick(n => n + 1), 15_000)
174
+ return () => clearInterval(id)
175
+ }, [tsFormat, showTimestamps])
75
176
 
76
177
  // Level-filtered entries
77
178
  // 'unknown' logs are shown when all 4 known levels are enabled (no active filtering)
@@ -112,6 +213,18 @@ export function LogCore({
112
213
  return () => window.removeEventListener('click', handleClick)
113
214
  }, [showDownloadMenu])
114
215
 
216
+ // Same close-on-outside-click for the timestamp format menu.
217
+ const tsMenuRef = useRef<HTMLDivElement>(null)
218
+ useEffect(() => {
219
+ if (!showTsMenu) return
220
+ const handleClick = (e: MouseEvent) => {
221
+ if (tsMenuRef.current?.contains(e.target as Node)) return
222
+ setShowTsMenu(false)
223
+ }
224
+ window.addEventListener('click', handleClick)
225
+ return () => window.removeEventListener('click', handleClick)
226
+ }, [showTsMenu])
227
+
115
228
  // Keyboard shortcut: Ctrl+F to open search
116
229
  useEffect(() => {
117
230
  const handleKeyDown = (e: KeyboardEvent) => {
@@ -133,14 +246,6 @@ export function LogCore({
133
246
  setAtBottom(bottom)
134
247
  }, [])
135
248
 
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
249
  const toggleWrap = useCallback(() => {
145
250
  setWordWrap(prev => {
146
251
  const next = !prev
@@ -157,6 +262,50 @@ export function LogCore({
157
262
  })
158
263
  }, [])
159
264
 
265
+ const pickTsFormat = useCallback((fmt: TimestampFormat) => {
266
+ setTsFormat(fmt)
267
+ try { localStorage.setItem('radar-logs-ts-format', fmt) } catch {}
268
+ // Auto-show timestamps when user picks a format — otherwise the change isn't visible.
269
+ setShowTimestamps(true)
270
+ try { localStorage.setItem('radar-logs-timestamps', 'true') } catch {}
271
+ setShowTsMenu(false)
272
+ }, [])
273
+
274
+ const toggleAnsi = useCallback(() => {
275
+ setAnsiEnabled(prev => {
276
+ const next = !prev
277
+ try { localStorage.setItem('radar-logs-ansi', String(next)) } catch {}
278
+ return next
279
+ })
280
+ }, [])
281
+
282
+ const toggleCollapseStacks = useCallback(() => {
283
+ setCollapseStacks(prev => {
284
+ const next = !prev
285
+ try { localStorage.setItem('radar-logs-collapse-stacks', String(next)) } catch {}
286
+ return next
287
+ })
288
+ }, [])
289
+
290
+ const toggleStackExpanded = useCallback((id: number) => {
291
+ setExpandedStacks(prev => {
292
+ const next = new Set(prev)
293
+ if (next.has(id)) next.delete(id)
294
+ else next.add(id)
295
+ return next
296
+ })
297
+ }, [])
298
+
299
+ // Clicking a filter chip in a structured log value pushes the value into the
300
+ // log search and enables filter mode so only matching lines are shown.
301
+ const handleFilterValue = useCallback((value: string) => {
302
+ search.setQuery(value)
303
+ // Values often contain regex metacharacters — force literal-substring matching.
304
+ search.setIsRegex(false)
305
+ search.setFilterMode(true)
306
+ if (!search.isOpen) search.open()
307
+ }, [search])
308
+
160
309
  const toggleLevel = useCallback((level: LogLevel) => {
161
310
  setEnabledLevels(prev => {
162
311
  const next = new Set(prev)
@@ -176,11 +325,57 @@ export function LogCore({
176
325
  : levelFilteredEntries[search.matchIndices[search.currentMatch]]?.id)
177
326
  : -1
178
327
 
328
+ // Group stack-trace continuation lines under their preceding head line.
329
+ // Disabled while search is active so matches inside continuations remain visible.
330
+ const groupedEntries = useMemo<LogGroup[]>(() => {
331
+ if (!collapseStacks || search.query) {
332
+ return displayEntries.map(e => ({ head: e, continuations: [] }))
333
+ }
334
+ const groups: LogGroup[] = []
335
+ for (const entry of displayEntries) {
336
+ if (groups.length > 0 && isContinuationLine(entry.content)) {
337
+ groups[groups.length - 1].continuations.push(entry)
338
+ } else {
339
+ groups.push({ head: entry, continuations: [] })
340
+ }
341
+ }
342
+ return groups
343
+ }, [displayEntries, collapseStacks, search.query])
344
+
345
+ const scrollToBottom = useCallback(() => {
346
+ virtuosoRef.current?.scrollToIndex({
347
+ index: groupedEntries.length - 1,
348
+ align: 'end',
349
+ behavior: 'smooth',
350
+ })
351
+ }, [groupedEntries.length])
352
+
353
+ const toolbarExtraNode = typeof toolbarExtra === 'function'
354
+ ? toolbarExtra({ isDark, palette })
355
+ : toolbarExtra
356
+
357
+ // Composite "inactive toolbar button" classes — static literals so
358
+ // Tailwind's class scanner picks them up. Two variants for secondary
359
+ // vs tertiary default text color.
360
+ const iconBtnInactive = isDark
361
+ ? 'p-1.5 rounded transition-colors text-slate-400 hover:text-slate-100 hover:bg-slate-800'
362
+ : 'p-1.5 rounded transition-colors text-slate-600 hover:text-slate-900 hover:bg-slate-200'
363
+ const iconBtnInactiveTertiary = isDark
364
+ ? 'p-1.5 rounded transition-colors text-slate-500 hover:text-slate-100 hover:bg-slate-800'
365
+ : 'p-1.5 rounded transition-colors text-slate-400 hover:text-slate-900 hover:bg-slate-200'
366
+ // "disabled → tertiary on hover" for inactive level filter chips.
367
+ const levelChipInactive = isDark
368
+ ? 'border-transparent text-slate-600 hover:text-slate-500'
369
+ : 'border-transparent text-slate-300 hover:text-slate-400'
370
+
179
371
  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" }}>
372
+ <div
373
+ className={`flex flex-col h-full ${palette.containerBg}`}
374
+ style={{ colorScheme: isDark ? 'dark' : 'light', fontFamily: "'SF Mono', 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'DejaVu Sans Mono', monospace" }}
375
+ >
181
376
  {/* Toolbar */}
182
- <div className="flex items-center gap-2 px-3 py-2 border-b border-theme-border bg-theme-surface">
183
- {toolbarExtra}
377
+ <div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBg}`}>
378
+ {toolbarExtraNode}
184
379
 
185
380
  {/* Stream / Stop toggle — only shown when streaming is supported */}
186
381
  {onStartStream && (
@@ -190,7 +385,7 @@ export function LogCore({
190
385
  className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded transition-colors ${
191
386
  isStreaming
192
387
  ? 'bg-green-600 text-white hover:bg-green-700'
193
- : 'bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover'
388
+ : `${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg}`
194
389
  }`}
195
390
  >
196
391
  {isStreaming ? <Square className="w-3 h-3" /> : <Play className="w-3 h-3" />}
@@ -204,7 +399,7 @@ export function LogCore({
204
399
  <button
205
400
  onClick={onRefresh}
206
401
  disabled={isLoading || isStreaming}
207
- className="flex items-center gap-1.5 px-2 py-1.5 text-xs rounded bg-theme-elevated text-theme-text-secondary hover:bg-theme-hover disabled:opacity-50 disabled:cursor-not-allowed"
402
+ className={`flex items-center gap-1.5 px-2 py-1.5 text-xs rounded ${palette.elevatedBg} ${palette.textSecondary} ${palette.hoverBg} disabled:opacity-50 disabled:cursor-not-allowed`}
208
403
  >
209
404
  <RotateCcw className={`w-3 h-3 ${isLoading ? 'animate-spin' : ''}`} />
210
405
  </button>
@@ -221,8 +416,8 @@ export function LogCore({
221
416
  onClick={() => toggleLevel(opt.level)}
222
417
  className={`px-1.5 py-0.5 text-[10px] font-medium rounded border transition-colors ${
223
418
  active
224
- ? opt.activeColor
225
- : 'border-transparent text-theme-text-disabled hover:text-theme-text-tertiary'
419
+ ? getLevelActiveColor(opt.level, palette)
420
+ : levelChipInactive
226
421
  }`}
227
422
  >
228
423
  {opt.label}{count > 0 ? ` ${count}` : ''}
@@ -240,7 +435,7 @@ export function LogCore({
240
435
  <button
241
436
  onClick={() => setExpandAllStructured(prev => !prev)}
242
437
  className={`p-1.5 rounded transition-colors ${
243
- expandAllStructured ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
438
+ expandAllStructured ? palette.toolbarActive : iconBtnInactive
244
439
  }`}
245
440
  >
246
441
  <Braces className="w-4 h-4" />
@@ -248,15 +443,76 @@ export function LogCore({
248
443
  </Tooltip>
249
444
  )}
250
445
 
251
- {/* Timestamp toggle */}
252
- <Tooltip content={showTimestamps ? 'Hide timestamps' : 'Show timestamps'} delay={TIP_DELAY} position="bottom">
446
+ {/* Timestamp toggle + format picker */}
447
+ <div className="flex items-center">
448
+ <Tooltip content={showTimestamps ? 'Hide timestamps' : 'Show timestamps'} delay={TIP_DELAY} position="bottom">
449
+ <button
450
+ onClick={toggleTimestamps}
451
+ className={`p-1.5 rounded-l transition-colors ${
452
+ showTimestamps ? palette.toolbarActive : iconBtnInactive
453
+ }`}
454
+ >
455
+ <Clock className="w-4 h-4" />
456
+ </button>
457
+ </Tooltip>
458
+ <div className="relative" ref={tsMenuRef}>
459
+ <Tooltip content={`Timestamp format: ${TIMESTAMP_FORMAT_LABELS[tsFormat]}`} delay={TIP_DELAY} position="bottom">
460
+ <button
461
+ onClick={() => setShowTsMenu(prev => !prev)}
462
+ className={`px-2 py-1.5 rounded-r text-[10px] font-medium transition-colors whitespace-nowrap ${
463
+ showTimestamps ? palette.toolbarActive : iconBtnInactiveTertiary
464
+ }`}
465
+ aria-label="Pick timestamp format"
466
+ >
467
+ <span className="inline-flex items-center gap-1">
468
+ <span>{TIMESTAMP_FORMAT_SHORT_LABELS[tsFormat]}</span>
469
+ <ChevronDown className="w-3 h-3" />
470
+ </span>
471
+ </button>
472
+ </Tooltip>
473
+ {showTsMenu && (
474
+ <div className={`absolute top-full right-0 mt-1 w-44 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50`}>
475
+ <div className={`px-3 py-1.5 text-[10px] uppercase tracking-wide ${palette.textTertiary} border-b ${palette.border}`}>
476
+ Timestamp format
477
+ </div>
478
+ {TIMESTAMP_FORMAT_ORDER.map(fmt => (
479
+ <button
480
+ key={fmt}
481
+ onClick={() => pickTsFormat(fmt)}
482
+ className={`w-full text-left px-3 py-1.5 text-xs ${palette.hoverBg} flex items-center justify-between ${
483
+ tsFormat === fmt ? palette.textPrimary : palette.textSecondary
484
+ }`}
485
+ >
486
+ <span>{TIMESTAMP_FORMAT_LABELS[fmt]}</span>
487
+ {tsFormat === fmt && <span className={`text-[10px] ${palette.textAccent}`}>✓</span>}
488
+ </button>
489
+ ))}
490
+ </div>
491
+ )}
492
+ </div>
493
+ </div>
494
+
495
+ {/* Collapse stack-trace continuations toggle */}
496
+ <Tooltip content={collapseStacks ? 'Stop grouping stack traces' : 'Group stack-trace lines'} delay={TIP_DELAY} position="bottom">
497
+ <button
498
+ onClick={toggleCollapseStacks}
499
+ className={`p-1.5 rounded transition-colors ${
500
+ collapseStacks ? palette.toolbarActive : iconBtnInactive
501
+ }`}
502
+ >
503
+ <ListCollapse className="w-4 h-4" />
504
+ </button>
505
+ </Tooltip>
506
+
507
+ {/* ANSI color rendering toggle */}
508
+ <Tooltip content={ansiEnabled ? 'Hide ANSI colors' : 'Render ANSI colors'} delay={TIP_DELAY} position="bottom">
253
509
  <button
254
- onClick={toggleTimestamps}
510
+ onClick={toggleAnsi}
255
511
  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'
512
+ ansiEnabled ? palette.toolbarActive : iconBtnInactive
257
513
  }`}
258
514
  >
259
- <Clock className="w-4 h-4" />
515
+ <Palette className="w-4 h-4" />
260
516
  </button>
261
517
  </Tooltip>
262
518
 
@@ -265,7 +521,7 @@ export function LogCore({
265
521
  <button
266
522
  onClick={toggleWrap}
267
523
  className={`p-1.5 rounded transition-colors ${
268
- wordWrap ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
524
+ wordWrap ? palette.toolbarActive : iconBtnInactive
269
525
  }`}
270
526
  >
271
527
  <WrapText className="w-4 h-4" />
@@ -277,30 +533,42 @@ export function LogCore({
277
533
  <button
278
534
  onClick={() => search.isOpen ? search.close() : search.open()}
279
535
  className={`p-1.5 rounded transition-colors ${
280
- search.isOpen ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
536
+ search.isOpen ? palette.toolbarActive : iconBtnInactive
281
537
  }`}
282
538
  >
283
539
  <Search className="w-4 h-4" />
284
540
  </button>
285
541
  </Tooltip>
286
542
 
543
+ {!themeLocked && (
544
+ <Tooltip content={isDark ? 'Switch to light mode' : 'Switch to dark mode'} delay={TIP_DELAY} position="bottom">
545
+ <button
546
+ onClick={toggleDark}
547
+ className={iconBtnInactive}
548
+ aria-label={isDark ? 'Switch log viewer to light mode' : 'Switch log viewer to dark mode'}
549
+ >
550
+ {isDark ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
551
+ </button>
552
+ </Tooltip>
553
+ )}
554
+
287
555
  {/* Download */}
288
556
  <div className="relative flex items-center" ref={downloadMenuRef}>
289
557
  <Tooltip content="Download logs" delay={TIP_DELAY} position="bottom">
290
558
  <button
291
559
  onClick={() => setShowDownloadMenu(prev => !prev)}
292
- className="p-1.5 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated"
560
+ className={iconBtnInactive}
293
561
  >
294
562
  <Download className="w-4 h-4" />
295
563
  </button>
296
564
  </Tooltip>
297
565
  {showDownloadMenu && (
298
- <div className="absolute top-full right-0 mt-1 w-32 bg-theme-elevated border border-theme-border rounded-lg shadow-lg z-50">
566
+ <div className={`absolute top-full right-0 mt-1 w-32 ${palette.menuBg} border ${palette.border} rounded-lg shadow-lg z-50`}>
299
567
  {(['txt', 'json', 'csv'] as DownloadFormat[]).map(fmt => (
300
568
  <button
301
569
  key={fmt}
302
570
  onClick={() => { onDownload(fmt); setShowDownloadMenu(false) }}
303
- className="w-full text-left px-3 py-2 text-xs text-theme-text-primary hover:bg-theme-hover first:rounded-t-lg last:rounded-b-lg"
571
+ className={`w-full text-left px-3 py-2 text-xs ${palette.textPrimary} ${palette.hoverBg} first:rounded-t-lg last:rounded-b-lg`}
304
572
  >
305
573
  {fmt.toUpperCase()}
306
574
  </button>
@@ -309,26 +577,12 @@ export function LogCore({
309
577
  )}
310
578
  </div>
311
579
 
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
580
  {/* Clear */}
327
581
  {onClear && (
328
582
  <Tooltip content="Clear logs" delay={TIP_DELAY} position="bottom">
329
583
  <button
330
584
  onClick={onClear}
331
- className="p-1.5 rounded text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated"
585
+ className={iconBtnInactive}
332
586
  >
333
587
  <Trash2 className="w-4 h-4" />
334
588
  </button>
@@ -338,8 +592,8 @@ export function LogCore({
338
592
 
339
593
  {/* Search bar */}
340
594
  {search.isOpen && (
341
- <div className="flex items-center gap-2 px-3 py-2 border-b border-theme-border bg-theme-surface/50">
342
- <Search className="w-4 h-4 text-theme-text-secondary shrink-0" />
595
+ <div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
596
+ <Search className={`w-4 h-4 ${palette.textSecondary} shrink-0`} />
343
597
  <input
344
598
  type="text"
345
599
  value={search.query}
@@ -356,7 +610,7 @@ export function LogCore({
356
610
  }
357
611
  }}
358
612
  placeholder="Search logs..."
359
- className="flex-1 bg-transparent text-theme-text-primary text-sm placeholder-theme-text-disabled focus:outline-none min-w-0"
613
+ className={`flex-1 bg-transparent ${palette.textPrimary} text-sm ${palette.placeholder} focus:outline-none min-w-0`}
360
614
  autoFocus
361
615
  />
362
616
 
@@ -365,7 +619,7 @@ export function LogCore({
365
619
  <button
366
620
  onClick={search.toggleRegex}
367
621
  className={`p-1 rounded transition-colors ${
368
- search.isRegex ? 'btn-brand-toggle' : 'text-theme-text-tertiary hover:text-theme-text-secondary'
622
+ search.isRegex ? palette.toolbarActive : `${palette.textTertiary} ${palette.hoverText}`
369
623
  }`}
370
624
  >
371
625
  <Regex className="w-3.5 h-3.5" />
@@ -377,7 +631,7 @@ export function LogCore({
377
631
  <button
378
632
  onClick={search.toggleCaseSensitive}
379
633
  className={`p-1 rounded transition-colors ${
380
- search.isCaseSensitive ? 'btn-brand-toggle' : 'text-theme-text-tertiary hover:text-theme-text-secondary'
634
+ search.isCaseSensitive ? palette.toolbarActive : `${palette.textTertiary} ${palette.hoverText}`
381
635
  }`}
382
636
  >
383
637
  <CaseSensitive className="w-3.5 h-3.5" />
@@ -389,7 +643,7 @@ export function LogCore({
389
643
  <button
390
644
  onClick={search.toggleFilterMode}
391
645
  className={`p-1 rounded transition-colors ${
392
- search.isFilterMode ? 'btn-brand-toggle' : 'text-theme-text-tertiary hover:text-theme-text-secondary'
646
+ search.isFilterMode ? palette.toolbarActive : `${palette.textTertiary} ${palette.hoverText}`
393
647
  }`}
394
648
  >
395
649
  <Filter className="w-3.5 h-3.5" />
@@ -398,7 +652,7 @@ export function LogCore({
398
652
 
399
653
  {search.query && (
400
654
  <>
401
- <span className={`text-xs whitespace-nowrap ${search.regexError ? 'text-red-400' : 'text-theme-text-tertiary'}`}>
655
+ <span className={`text-xs whitespace-nowrap ${search.regexError ? palette.textError : palette.textTertiary}`}>
402
656
  {search.regexError
403
657
  ? 'Invalid regex'
404
658
  : search.matchCount > 0
@@ -411,7 +665,7 @@ export function LogCore({
411
665
  <button
412
666
  onClick={search.goToPrev}
413
667
  disabled={search.matchCount === 0}
414
- className="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-30"
668
+ className={`p-1 rounded ${palette.textSecondary} ${palette.hoverText} disabled:opacity-30`}
415
669
  >
416
670
  <ChevronUp className="w-3.5 h-3.5" />
417
671
  </button>
@@ -420,7 +674,7 @@ export function LogCore({
420
674
  <button
421
675
  onClick={search.goToNext}
422
676
  disabled={search.matchCount === 0}
423
- className="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary disabled:opacity-30"
677
+ className={`p-1 rounded ${palette.textSecondary} ${palette.hoverText} disabled:opacity-30`}
424
678
  >
425
679
  <ChevronDown className="w-3.5 h-3.5" />
426
680
  </button>
@@ -428,7 +682,7 @@ export function LogCore({
428
682
 
429
683
  <button
430
684
  onClick={() => search.setQuery('')}
431
- className="p-1 rounded text-theme-text-secondary hover:text-theme-text-primary"
685
+ className={`p-1 rounded ${palette.textSecondary} ${palette.hoverText}`}
432
686
  >
433
687
  <X className="w-3 h-3" />
434
688
  </button>
@@ -439,19 +693,19 @@ export function LogCore({
439
693
 
440
694
  {/* Log content */}
441
695
  {isLoading && entries.length === 0 ? (
442
- <div className="flex-1 flex items-center justify-center text-theme-text-tertiary">
696
+ <div className={`flex-1 flex items-center justify-center ${palette.textTertiary}`}>
443
697
  <div className="flex items-center gap-2">
444
698
  <RotateCcw className="w-4 h-4 animate-spin" />
445
699
  <span>Loading logs...</span>
446
700
  </div>
447
701
  </div>
448
702
  ) : errorMessage ? (
449
- <div className="flex-1 flex flex-col items-center justify-center text-red-400 gap-2">
703
+ <div className={`flex-1 flex flex-col items-center justify-center gap-2 ${palette.textError}`}>
450
704
  <Terminal className="w-8 h-8" />
451
705
  <span>{errorMessage}</span>
452
706
  </div>
453
- ) : displayEntries.length === 0 ? (
454
- <div className="flex-1 flex flex-col items-center justify-center text-theme-text-tertiary gap-2">
707
+ ) : groupedEntries.length === 0 ? (
708
+ <div className={`flex-1 flex flex-col items-center justify-center gap-2 ${palette.textTertiary}`}>
455
709
  <Terminal className="w-8 h-8" />
456
710
  <span>{emptyMessage}</span>
457
711
  </div>
@@ -459,23 +713,30 @@ export function LogCore({
459
713
  <div className="flex-1 relative">
460
714
  <Virtuoso
461
715
  ref={virtuosoRef}
462
- data={displayEntries}
716
+ data={groupedEntries}
463
717
  followOutput={handleFollowOutput}
464
- initialTopMostItemIndex={displayEntries.length - 1}
718
+ initialTopMostItemIndex={groupedEntries.length - 1}
465
719
  atBottomStateChange={handleAtBottomStateChange}
466
720
  atBottomThreshold={50}
467
721
  increaseViewportBy={200}
468
- itemContent={(_index, entry) => (
469
- <LogLine
470
- entry={entry}
722
+ itemContent={(_index, group) => (
723
+ <LogGroupItem
724
+ group={group}
471
725
  searchQuery={search.query}
472
726
  searchIsRegex={search.isRegex}
473
727
  searchIsCaseSensitive={search.isCaseSensitive}
474
728
  showPodName={showPodName}
475
729
  showTimestamp={showTimestamps}
476
- isCurrentMatch={entry.id === currentHighlightId}
730
+ tsFormat={tsFormat}
731
+ ansiEnabled={ansiEnabled}
732
+ isCurrentMatch={group.head.id === currentHighlightId}
477
733
  wordWrap={wordWrap}
478
734
  defaultExpanded={expandAllStructured}
735
+ onFilterValue={handleFilterValue}
736
+ isStackExpanded={expandedStacks.has(group.head.id)}
737
+ onToggleStack={toggleStackExpanded}
738
+ isDark={isDark}
739
+ palette={palette}
479
740
  />
480
741
  )}
481
742
  className="h-full font-mono text-xs"
@@ -493,49 +754,67 @@ export function LogCore({
493
754
  )}
494
755
 
495
756
  {/* Keyboard shortcut hints */}
496
- <div className="flex items-center gap-4 px-3 py-1 border-t border-theme-border bg-theme-surface text-[10px] text-theme-text-disabled">
497
- <Shortcut keys="Ctrl+F" label="Search" />
498
- <Shortcut keys="Enter" label="Next match" />
499
- <Shortcut keys="Shift+Enter" label="Prev match" />
500
- <Shortcut keys="Esc" label="Close search" />
757
+ <div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
758
+ <Shortcut keys="Ctrl+F" label="Search" palette={palette} />
759
+ <Shortcut keys="Enter" label="Next match" palette={palette} />
760
+ <Shortcut keys="Shift+Enter" label="Prev match" palette={palette} />
761
+ <Shortcut keys="Esc" label="Close search" palette={palette} />
501
762
  </div>
502
763
  </div>
503
764
  )
504
765
  }
505
766
 
506
- function Shortcut({ keys, label }: { keys: string; label: string }) {
767
+ function Shortcut({ keys, label, palette }: { keys: string; label: string; palette: LogPalette }) {
507
768
  return (
508
769
  <span className="flex items-center gap-1">
509
- <kbd className="px-1 py-px rounded bg-theme-elevated border border-theme-border-light font-mono">{keys}</kbd>
770
+ <kbd className={`px-1 py-px rounded ${palette.elevatedBg} border ${palette.borderLight} font-mono`}>{keys}</kbd>
510
771
  <span>{label}</span>
511
772
  </span>
512
773
  )
513
774
  }
514
775
 
515
- function LogLine({
516
- entry,
517
- searchQuery,
518
- searchIsRegex,
519
- searchIsCaseSensitive,
520
- showPodName,
521
- showTimestamp,
522
- isCurrentMatch,
523
- wordWrap,
524
- defaultExpanded,
525
- }: {
776
+ interface LogLineProps {
526
777
  entry: LogEntry
527
778
  searchQuery: string
528
779
  searchIsRegex: boolean
529
780
  searchIsCaseSensitive: boolean
530
781
  showPodName: boolean
531
782
  showTimestamp: boolean
783
+ tsFormat: TimestampFormat
784
+ ansiEnabled: boolean
532
785
  isCurrentMatch: boolean
533
786
  wordWrap: boolean
534
787
  defaultExpanded: boolean
535
- }) {
536
- const levelColor = getLevelColor(entry.level)
788
+ onFilterValue?: (value: string) => void
789
+ /** Optional lead element rendered at the start of the row (e.g. stack-trace toggle). */
790
+ leadSlot?: ReactNode
791
+ isDark: boolean
792
+ palette: LogPalette
793
+ }
537
794
 
538
- // Determine content rendering
795
+ function LogLine({
796
+ entry,
797
+ searchQuery,
798
+ searchIsRegex,
799
+ searchIsCaseSensitive,
800
+ showPodName,
801
+ showTimestamp,
802
+ tsFormat,
803
+ ansiEnabled,
804
+ isCurrentMatch,
805
+ wordWrap,
806
+ defaultExpanded,
807
+ onFilterValue,
808
+ leadSlot,
809
+ isDark,
810
+ palette,
811
+ }: LogLineProps) {
812
+ const levelColor = getLogLevelColor(entry.level, isDark)
813
+ const podTextColor = entry.podColorIndex !== undefined
814
+ ? palette.podColors[entry.podColorIndex % palette.podColors.length].text
815
+ : palette.textPrimary
816
+
817
+ // Determine content rendering. Priority: search highlight > structured > ANSI/plain.
539
818
  let contentElement: React.ReactNode
540
819
  if (searchQuery) {
541
820
  const plain = stripAnsi(entry.content)
@@ -554,9 +833,11 @@ function LogLine({
554
833
  wordWrap={wordWrap}
555
834
  isLogfmt={entry.isLogfmt}
556
835
  defaultExpanded={defaultExpanded}
836
+ onFilterValue={onFilterValue}
837
+ isDark={isDark}
557
838
  />
558
839
  )
559
- } else {
840
+ } else if (ansiEnabled) {
560
841
  const html = ansiToHtml(entry.content)
561
842
  contentElement = (
562
843
  <span
@@ -564,6 +845,12 @@ function LogLine({
564
845
  dangerouslySetInnerHTML={{ __html: html }}
565
846
  />
566
847
  )
848
+ } else {
849
+ contentElement = (
850
+ <span className={`${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'} ${levelColor}`}>
851
+ {stripAnsi(entry.content)}
852
+ </span>
853
+ )
567
854
  }
568
855
 
569
856
  const handleCopy = () => {
@@ -572,15 +859,19 @@ function LogLine({
572
859
  }
573
860
 
574
861
  return (
575
- <div className={`flex hover:bg-theme-surface/50 group leading-5 px-2 ${isCurrentMatch ? 'bg-yellow-500/10' : ''}`}>
862
+ <div className={`flex ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
863
+ {leadSlot}
576
864
  {showTimestamp && entry.timestamp && (
577
- <span className="text-theme-text-tertiary select-none pr-2 whitespace-nowrap">
578
- {formatLogTimestamp(entry.timestamp)}
865
+ <span
866
+ className={`${palette.textTertiary} select-none pr-2 whitespace-nowrap`}
867
+ title={entry.timestamp}
868
+ >
869
+ {formatLogTimestamp(entry.timestamp, tsFormat)}
579
870
  </span>
580
871
  )}
581
872
  {showPodName && entry.pod && (
582
873
  <span
583
- className={`${entry.podColor || 'text-theme-text-primary'} select-none pr-2 whitespace-nowrap min-w-[80px] max-w-[120px] truncate`}
874
+ className={`${podTextColor} select-none pr-2 whitespace-nowrap min-w-[80px] max-w-[120px] truncate`}
584
875
  title={entry.pod}
585
876
  >
586
877
  [{entry.pod.split('-').slice(-2).join('-')}]
@@ -589,7 +880,7 @@ function LogLine({
589
880
  <span className="flex-1 min-w-0">{contentElement}</span>
590
881
  <button
591
882
  onClick={handleCopy}
592
- className="opacity-0 group-hover:opacity-100 ml-1 p-0.5 rounded text-theme-text-tertiary hover:text-theme-text-secondary shrink-0 transition-opacity"
883
+ className={`opacity-0 group-hover:opacity-100 ml-1 p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} shrink-0 transition-opacity`}
593
884
  title="Copy line"
594
885
  >
595
886
  <Copy className="w-3 h-3" />
@@ -597,3 +888,63 @@ function LogLine({
597
888
  </div>
598
889
  )
599
890
  }
891
+
892
+ interface LogGroupItemProps {
893
+ group: LogGroup
894
+ searchQuery: string
895
+ searchIsRegex: boolean
896
+ searchIsCaseSensitive: boolean
897
+ showPodName: boolean
898
+ showTimestamp: boolean
899
+ tsFormat: TimestampFormat
900
+ ansiEnabled: boolean
901
+ isCurrentMatch: boolean
902
+ wordWrap: boolean
903
+ defaultExpanded: boolean
904
+ onFilterValue: (value: string) => void
905
+ isStackExpanded: boolean
906
+ onToggleStack: (id: number) => void
907
+ isDark: boolean
908
+ palette: LogPalette
909
+ }
910
+
911
+ function LogGroupItem(props: LogGroupItemProps) {
912
+ const { group, isStackExpanded, onToggleStack, palette, ...rest } = props
913
+ const hasStack = group.continuations.length > 0
914
+
915
+ const stackToggle = hasStack ? (
916
+ <button
917
+ onClick={() => onToggleStack(group.head.id)}
918
+ className={`mr-1 self-start p-0.5 rounded ${palette.textTertiary} ${palette.hoverText} ${palette.hoverSurface} shrink-0`}
919
+ title={isStackExpanded ? 'Collapse stack trace' : `Expand ${group.continuations.length} stack frames`}
920
+ >
921
+ {isStackExpanded
922
+ ? <ChevronDown className="w-3 h-3" />
923
+ : <ChevronRight className="w-3 h-3" />}
924
+ </button>
925
+ ) : null
926
+
927
+ return (
928
+ <div>
929
+ <LogLine
930
+ entry={group.head}
931
+ {...rest}
932
+ palette={palette}
933
+ leadSlot={stackToggle}
934
+ />
935
+ {hasStack && !isStackExpanded && (
936
+ <button
937
+ onClick={() => onToggleStack(group.head.id)}
938
+ className={`block w-full text-left pl-6 pr-2 py-0 text-[10px] ${palette.textTertiary} ${palette.hoverText} ${palette.hoverSurface}`}
939
+ >
940
+ [+{group.continuations.length} stack {group.continuations.length === 1 ? 'line' : 'lines'}]
941
+ </button>
942
+ )}
943
+ {hasStack && isStackExpanded && group.continuations.map(cont => (
944
+ <div key={cont.id} className="pl-4">
945
+ <LogLine entry={cont} {...rest} palette={palette} isCurrentMatch={false} />
946
+ </div>
947
+ ))}
948
+ </div>
949
+ )
950
+ }