@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.
- package/package.json +1 -1
- package/src/components/logs/JsonLogLine.tsx +10 -7
- package/src/components/logs/LogCore.tsx +451 -100
- package/src/components/logs/LogToolbarSelects.tsx +17 -6
- package/src/components/logs/LogsViewer.tsx +8 -7
- package/src/components/logs/StructuredLogLine.tsx +125 -43
- package/src/components/logs/WorkloadLogsViewer.tsx +44 -34
- package/src/components/logs/log-palette.ts +222 -0
- package/src/components/logs/useLogBuffer.ts +6 -1
- package/src/components/logs/useLogSearch.ts +5 -0
- package/src/components/resources/resource-utils-argo.ts +9 -2
- package/src/utils/log-format.ts +77 -12
|
@@ -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?:
|
|
37
|
+
toolbarExtra?: ToolbarExtraRenderer
|
|
28
38
|
showPodName?: boolean
|
|
29
39
|
emptyMessage?: string
|
|
30
40
|
errorMessage?: string | null
|
|
31
|
-
/**
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
112
|
+
forceDark,
|
|
58
113
|
}: LogCoreProps) {
|
|
59
114
|
const virtuosoRef = useRef<VirtuosoHandle>(null)
|
|
60
115
|
const [atBottom, setAtBottom] = useState(true)
|
|
61
|
-
const
|
|
62
|
-
|
|
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
|
|
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=
|
|
183
|
-
{
|
|
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
|
-
:
|
|
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=
|
|
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.
|
|
225
|
-
:
|
|
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 ?
|
|
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
|
-
<
|
|
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={
|
|
510
|
+
onClick={toggleAnsi}
|
|
255
511
|
className={`p-1.5 rounded transition-colors ${
|
|
256
|
-
|
|
512
|
+
ansiEnabled ? palette.toolbarActive : iconBtnInactive
|
|
257
513
|
}`}
|
|
258
514
|
>
|
|
259
|
-
<
|
|
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 ?
|
|
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 ?
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
342
|
-
<Search className=
|
|
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=
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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 ?
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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
|
-
) :
|
|
454
|
-
<div className=
|
|
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={
|
|
716
|
+
data={groupedEntries}
|
|
463
717
|
followOutput={handleFollowOutput}
|
|
464
|
-
initialTopMostItemIndex={
|
|
718
|
+
initialTopMostItemIndex={groupedEntries.length - 1}
|
|
465
719
|
atBottomStateChange={handleAtBottomStateChange}
|
|
466
720
|
atBottomThreshold={50}
|
|
467
721
|
increaseViewportBy={200}
|
|
468
|
-
itemContent={(_index,
|
|
469
|
-
<
|
|
470
|
-
|
|
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
|
-
|
|
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=
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
862
|
+
<div className={`flex ${palette.hoverSurface} group leading-5 px-2 ${isCurrentMatch ? palette.currentMatchBg : ''}`}>
|
|
863
|
+
{leadSlot}
|
|
576
864
|
{showTimestamp && entry.timestamp && (
|
|
577
|
-
<span
|
|
578
|
-
{
|
|
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={`${
|
|
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=
|
|
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
|
+
}
|