@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 +2 -2
- package/src/components/dock/LocalTerminalTab.tsx +29 -29
- package/src/components/dock/TerminalTab.tsx +29 -29
- package/src/components/logs/LogCore.tsx +310 -56
- package/src/components/logs/StructuredLogLine.tsx +98 -15
- package/src/components/logs/useLogSearch.ts +5 -0
- package/src/components/resources/ResourcesView.tsx +139 -139
- package/src/components/resources/renderers/NodeRenderer.tsx +45 -31
- package/src/components/resources/resource-utils-argo.ts +9 -2
- package/src/components/ui/HealthRing.tsx +2 -2
- package/src/components/ui/drawer-components.tsx +108 -6
- package/src/topology.css +6 -0
- package/src/utils/log-format.ts +77 -12
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@skyhook-io/k8s-ui",
|
|
3
|
-
"version": "1.5.
|
|
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.
|
|
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,
|
|
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
|
-
/**
|
|
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 =
|
|
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
|
|
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
|
-
<
|
|
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={
|
|
438
|
+
onClick={toggleAnsi}
|
|
255
439
|
className={`p-1.5 rounded transition-colors ${
|
|
256
|
-
|
|
440
|
+
ansiEnabled ? 'btn-brand-toggle' : 'text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated'
|
|
257
441
|
}`}
|
|
258
442
|
>
|
|
259
|
-
<
|
|
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
|
-
) :
|
|
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={
|
|
632
|
+
data={groupedEntries}
|
|
463
633
|
followOutput={handleFollowOutput}
|
|
464
|
-
initialTopMostItemIndex={
|
|
634
|
+
initialTopMostItemIndex={groupedEntries.length - 1}
|
|
465
635
|
atBottomStateChange={handleAtBottomStateChange}
|
|
466
636
|
atBottomThreshold={50}
|
|
467
637
|
increaseViewportBy={200}
|
|
468
|
-
itemContent={(_index,
|
|
469
|
-
<
|
|
470
|
-
|
|
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
|
-
|
|
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
|
-
|
|
527
|
-
|
|
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
|
|
578
|
-
|
|
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
|
+
}
|