@skyhook-io/k8s-ui 1.5.1 → 1.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
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
|
+
}
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { useState, useMemo } from 'react'
|
|
2
|
-
import { ChevronRight, ChevronDown } from 'lucide-react'
|
|
2
|
+
import { ChevronRight, ChevronDown, Filter } from 'lucide-react'
|
|
3
3
|
import type { LogLevel } from './useLogBuffer'
|
|
4
4
|
import {
|
|
5
5
|
getLevelColor,
|
|
6
|
-
highlightJson,
|
|
7
6
|
unescapeJsonStrings,
|
|
8
7
|
parseLogfmt,
|
|
9
8
|
SYNTAX_COLOR_KEY,
|
|
10
9
|
SYNTAX_COLOR_STRING,
|
|
10
|
+
SYNTAX_COLOR_NUMBER,
|
|
11
|
+
SYNTAX_COLOR_BOOLEAN,
|
|
12
|
+
SYNTAX_COLOR_NULL,
|
|
11
13
|
} from '../../utils/log-format'
|
|
12
14
|
import { SEVERITY_BADGE_BORDERED } from '../../utils/badge-colors'
|
|
13
15
|
|
|
@@ -17,9 +19,15 @@ interface StructuredLogLineProps {
|
|
|
17
19
|
wordWrap: boolean
|
|
18
20
|
isLogfmt?: boolean
|
|
19
21
|
defaultExpanded?: boolean
|
|
22
|
+
/**
|
|
23
|
+
* When provided, field values in the expanded view become filterable — hovering
|
|
24
|
+
* shows a chip that, on click, calls onFilterValue(value) so the parent can
|
|
25
|
+
* add the value to the log search/filter.
|
|
26
|
+
*/
|
|
27
|
+
onFilterValue?: (value: string) => void
|
|
20
28
|
}
|
|
21
29
|
|
|
22
|
-
export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded }: StructuredLogLineProps) {
|
|
30
|
+
export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultExpanded, onFilterValue }: StructuredLogLineProps) {
|
|
23
31
|
// null = user hasn't toggled this line; defers to defaultExpanded (global toggle)
|
|
24
32
|
const [localExpanded, setLocalExpanded] = useState<boolean | null>(null)
|
|
25
33
|
const expanded = localExpanded ?? defaultExpanded ?? false
|
|
@@ -75,11 +83,12 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
|
|
|
75
83
|
</span>
|
|
76
84
|
<span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
|
|
77
85
|
{isLogfmt ? (
|
|
78
|
-
<ExpandedLogfmt obj={parsed} />
|
|
86
|
+
<ExpandedLogfmt obj={parsed} onFilterValue={onFilterValue} />
|
|
79
87
|
) : (
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
88
|
+
<JsonExpanded
|
|
89
|
+
text={unescapeJsonStrings(JSON.stringify(parsed, null, 2))}
|
|
90
|
+
onFilterValue={onFilterValue}
|
|
91
|
+
/>
|
|
83
92
|
)}
|
|
84
93
|
</span>
|
|
85
94
|
</>
|
|
@@ -88,6 +97,77 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
|
|
|
88
97
|
)
|
|
89
98
|
}
|
|
90
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Render a primitive log-field value with an optional filter chip that appears on hover.
|
|
102
|
+
* Clicking the chip calls onFilter(value) so the caller can push it into log search.
|
|
103
|
+
*/
|
|
104
|
+
function FilterableValue({
|
|
105
|
+
value, onFilter, color,
|
|
106
|
+
}: { value: string; onFilter?: (v: string) => void; color?: string }) {
|
|
107
|
+
if (!onFilter) {
|
|
108
|
+
return <span style={color ? { color } : undefined}>{value}</span>
|
|
109
|
+
}
|
|
110
|
+
return (
|
|
111
|
+
<span className="group/flt inline-flex items-baseline align-baseline gap-0.5 rounded hover:bg-theme-surface/60">
|
|
112
|
+
<span style={color ? { color } : undefined}>{value}</span>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
onClick={(e) => { e.stopPropagation(); onFilter(value) }}
|
|
116
|
+
className="opacity-0 group-hover/flt:opacity-100 transition-opacity text-theme-text-tertiary hover:text-theme-text-primary px-0.5"
|
|
117
|
+
title={`Filter to lines containing "${value}"`}
|
|
118
|
+
aria-label={`Filter to lines containing ${value}`}
|
|
119
|
+
>
|
|
120
|
+
<Filter className="w-3 h-3 inline" />
|
|
121
|
+
</button>
|
|
122
|
+
</span>
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Render pretty-printed JSON with filterable primitive values while preserving
|
|
128
|
+
* the layout that JSON.stringify(..., null, 2) produces. Tokenizes the string
|
|
129
|
+
* and emits React nodes so the hover chip can be wired per value.
|
|
130
|
+
*/
|
|
131
|
+
function JsonExpanded({ text, onFilterValue }: { text: string; onFilterValue?: (v: string) => void }) {
|
|
132
|
+
const tokenRe = /("(?:\\.|[^"\\])*")\s*:|("(?:\\.|[^"\\])*")|(-?\b\d+(?:\.\d+)?(?:[eE][+-]?\d+)?\b)|\b(true|false)\b|\b(null)\b/g
|
|
133
|
+
const nodes: React.ReactNode[] = []
|
|
134
|
+
let lastIndex = 0
|
|
135
|
+
let match: RegExpExecArray | null
|
|
136
|
+
let idx = 0
|
|
137
|
+
while ((match = tokenRe.exec(text)) !== null) {
|
|
138
|
+
if (match.index > lastIndex) {
|
|
139
|
+
nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex, match.index)}</span>)
|
|
140
|
+
}
|
|
141
|
+
const [, key, str, num, bool, nil] = match
|
|
142
|
+
if (key !== undefined) {
|
|
143
|
+
nodes.push(<span key={`k${idx++}`} style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>)
|
|
144
|
+
nodes.push(<span key={`c${idx++}`}>:</span>)
|
|
145
|
+
} else if (str !== undefined) {
|
|
146
|
+
// Unescape the quoted string for the filter value (users expect to filter on
|
|
147
|
+
// the displayed string, not JSON-escaped bytes).
|
|
148
|
+
const inner = str.slice(1, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\')
|
|
149
|
+
nodes.push(
|
|
150
|
+
<FilterableValue key={`s${idx++}`} value={inner} onFilter={onFilterValue} color={SYNTAX_COLOR_STRING} />
|
|
151
|
+
)
|
|
152
|
+
} else if (num !== undefined) {
|
|
153
|
+
nodes.push(
|
|
154
|
+
<FilterableValue key={`n${idx++}`} value={num} onFilter={onFilterValue} color={SYNTAX_COLOR_NUMBER} />
|
|
155
|
+
)
|
|
156
|
+
} else if (bool !== undefined) {
|
|
157
|
+
nodes.push(
|
|
158
|
+
<FilterableValue key={`b${idx++}`} value={bool} onFilter={onFilterValue} color={SYNTAX_COLOR_BOOLEAN} />
|
|
159
|
+
)
|
|
160
|
+
} else if (nil !== undefined) {
|
|
161
|
+
nodes.push(<span key={`z${idx++}`} style={{ color: SYNTAX_COLOR_NULL }}>{nil}</span>)
|
|
162
|
+
}
|
|
163
|
+
lastIndex = tokenRe.lastIndex
|
|
164
|
+
}
|
|
165
|
+
if (lastIndex < text.length) {
|
|
166
|
+
nodes.push(<span key={`t${idx++}`}>{text.slice(lastIndex)}</span>)
|
|
167
|
+
}
|
|
168
|
+
return <>{nodes}</>
|
|
169
|
+
}
|
|
170
|
+
|
|
91
171
|
function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
|
|
92
172
|
const lvl = obj.level ?? obj.severity ?? obj.lvl ?? nestedField(obj, 'log', 'level')
|
|
93
173
|
const msg = obj.msg ?? obj.message
|
|
@@ -117,16 +197,19 @@ function SummaryLine({ obj }: { obj: Record<string, unknown> }) {
|
|
|
117
197
|
)
|
|
118
198
|
}
|
|
119
199
|
|
|
120
|
-
function ExpandedLogfmt({ obj }: { obj: Record<string, unknown
|
|
200
|
+
function ExpandedLogfmt({ obj, onFilterValue }: { obj: Record<string, unknown>; onFilterValue?: (v: string) => void }) {
|
|
121
201
|
return (
|
|
122
202
|
<>
|
|
123
|
-
{Object.entries(obj).map(([key, val]) =>
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
<
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
203
|
+
{Object.entries(obj).map(([key, val]) => {
|
|
204
|
+
const str = String(val)
|
|
205
|
+
return (
|
|
206
|
+
<div key={key}>
|
|
207
|
+
<span style={{ color: SYNTAX_COLOR_KEY }}>{key}</span>
|
|
208
|
+
<span className="text-theme-text-tertiary">=</span>
|
|
209
|
+
<FilterableValue value={str} onFilter={onFilterValue} color={SYNTAX_COLOR_STRING} />
|
|
210
|
+
</div>
|
|
211
|
+
)
|
|
212
|
+
})}
|
|
130
213
|
</>
|
|
131
214
|
)
|
|
132
215
|
}
|
|
@@ -8,10 +8,12 @@ interface UseLogSearchReturn {
|
|
|
8
8
|
setQuery: (q: string) => void
|
|
9
9
|
isRegex: boolean
|
|
10
10
|
toggleRegex: () => void
|
|
11
|
+
setIsRegex: (v: boolean) => void
|
|
11
12
|
isCaseSensitive: boolean
|
|
12
13
|
toggleCaseSensitive: () => void
|
|
13
14
|
isFilterMode: boolean
|
|
14
15
|
toggleFilterMode: () => void
|
|
16
|
+
setFilterMode: (v: boolean) => void
|
|
15
17
|
matchCount: number
|
|
16
18
|
currentMatch: number
|
|
17
19
|
/** Indices into the entries array that match */
|
|
@@ -121,6 +123,7 @@ export function useLogSearch(
|
|
|
121
123
|
const toggleRegex = useCallback(() => setIsRegex(p => !p), [])
|
|
122
124
|
const toggleCaseSensitive = useCallback(() => setIsCaseSensitive(p => !p), [])
|
|
123
125
|
const toggleFilterMode = useCallback(() => setIsFilterMode(p => !p), [])
|
|
126
|
+
const setFilterMode = useCallback((v: boolean) => setIsFilterMode(v), [])
|
|
124
127
|
|
|
125
128
|
const open = useCallback(() => setIsOpen(true), [])
|
|
126
129
|
const close = useCallback(() => {
|
|
@@ -133,10 +136,12 @@ export function useLogSearch(
|
|
|
133
136
|
setQuery,
|
|
134
137
|
isRegex,
|
|
135
138
|
toggleRegex,
|
|
139
|
+
setIsRegex,
|
|
136
140
|
isCaseSensitive,
|
|
137
141
|
toggleCaseSensitive,
|
|
138
142
|
isFilterMode,
|
|
139
143
|
toggleFilterMode,
|
|
144
|
+
setFilterMode,
|
|
140
145
|
matchCount: matchIndices.length,
|
|
141
146
|
currentMatch,
|
|
142
147
|
matchIndices,
|
|
@@ -12,9 +12,16 @@ export function getArgoApplicationStatus(app: any): StatusBadge {
|
|
|
12
12
|
const sync = app.status?.sync?.status
|
|
13
13
|
const opPhase = app.status?.operationState?.phase
|
|
14
14
|
|
|
15
|
-
// Check for suspended (no automated sync policy)
|
|
15
|
+
// Check for suspended (no automated sync policy). Honor both the current
|
|
16
|
+
// "radarhq.io/suspended-prune" annotation and the legacy "skyhook.io/..."
|
|
17
|
+
// key still present on Applications suspended by older Radar builds.
|
|
18
|
+
// The annotation value stores the prior prune state as "true"/"false" for
|
|
19
|
+
// restore on resume — both strings are truthy in JS, which is intentional:
|
|
20
|
+
// the *presence* of the annotation is what signals suspended, not its value.
|
|
16
21
|
const hasAutomatedSync = !!app.spec?.syncPolicy?.automated
|
|
17
|
-
|
|
22
|
+
const annotations = app.metadata?.annotations
|
|
23
|
+
const suspendedByRadar = annotations?.['radarhq.io/suspended-prune'] || annotations?.['skyhook.io/suspended-prune']
|
|
24
|
+
if (health === 'Suspended' || (!hasAutomatedSync && suspendedByRadar)) {
|
|
18
25
|
return { text: 'Suspended', color: healthColors.degraded, level: 'degraded' }
|
|
19
26
|
}
|
|
20
27
|
|
package/src/utils/log-format.ts
CHANGED
|
@@ -5,22 +5,87 @@
|
|
|
5
5
|
|
|
6
6
|
import type { LogLevel } from '../components/logs/useLogBuffer'
|
|
7
7
|
|
|
8
|
+
export type TimestampFormat =
|
|
9
|
+
| 'time-local'
|
|
10
|
+
| 'time-utc'
|
|
11
|
+
| 'iso-local'
|
|
12
|
+
| 'iso-utc'
|
|
13
|
+
| 'relative'
|
|
14
|
+
| 'epoch'
|
|
15
|
+
|
|
16
|
+
export const TIMESTAMP_FORMAT_LABELS: Record<TimestampFormat, string> = {
|
|
17
|
+
'time-local': 'Time (local)',
|
|
18
|
+
'time-utc': 'Time (UTC)',
|
|
19
|
+
'iso-local': 'ISO (local)',
|
|
20
|
+
'iso-utc': 'ISO (UTC)',
|
|
21
|
+
'relative': 'Relative',
|
|
22
|
+
'epoch': 'Epoch',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function pad2(n: number): string {
|
|
26
|
+
return n < 10 ? '0' + n : String(n)
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function formatIsoLocal(date: Date): string {
|
|
30
|
+
const offset = -date.getTimezoneOffset()
|
|
31
|
+
const sign = offset >= 0 ? '+' : '-'
|
|
32
|
+
const abs = Math.abs(offset)
|
|
33
|
+
return (
|
|
34
|
+
date.getFullYear() +
|
|
35
|
+
'-' + pad2(date.getMonth() + 1) +
|
|
36
|
+
'-' + pad2(date.getDate()) +
|
|
37
|
+
'T' + pad2(date.getHours()) +
|
|
38
|
+
':' + pad2(date.getMinutes()) +
|
|
39
|
+
':' + pad2(date.getSeconds()) +
|
|
40
|
+
sign + pad2(Math.floor(abs / 60)) + ':' + pad2(abs % 60)
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatRelative(date: Date, now: number): string {
|
|
45
|
+
const diffSec = Math.round((now - date.getTime()) / 1000)
|
|
46
|
+
const abs = Math.abs(diffSec)
|
|
47
|
+
const suffix = diffSec >= 0 ? ' ago' : ' from now'
|
|
48
|
+
if (abs < 2) return 'just now'
|
|
49
|
+
if (abs < 60) return `${abs}s${suffix}`
|
|
50
|
+
if (abs < 3600) return `${Math.floor(abs / 60)}m${suffix}`
|
|
51
|
+
if (abs < 86400) return `${Math.floor(abs / 3600)}h${suffix}`
|
|
52
|
+
return `${Math.floor(abs / 86400)}d${suffix}`
|
|
53
|
+
}
|
|
54
|
+
|
|
8
55
|
/**
|
|
9
56
|
* Format a K8s log timestamp for display.
|
|
10
|
-
*
|
|
57
|
+
* Supports multiple display formats and UTC/local time zones.
|
|
58
|
+
* `now` is accepted for testability; defaults to Date.now().
|
|
11
59
|
*/
|
|
12
|
-
export function formatLogTimestamp(
|
|
60
|
+
export function formatLogTimestamp(
|
|
61
|
+
ts: string,
|
|
62
|
+
format: TimestampFormat = 'time-local',
|
|
63
|
+
now?: number,
|
|
64
|
+
): string {
|
|
13
65
|
const date = new Date(ts)
|
|
14
66
|
if (isNaN(date.getTime())) {
|
|
15
|
-
// Fallback: extract HH:MM:SS from ISO timestamp
|
|
16
67
|
return ts.slice(11, 19) || ts
|
|
17
68
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
69
|
+
switch (format) {
|
|
70
|
+
case 'time-utc':
|
|
71
|
+
return date.toISOString().slice(11, 19)
|
|
72
|
+
case 'iso-local':
|
|
73
|
+
return formatIsoLocal(date)
|
|
74
|
+
case 'iso-utc':
|
|
75
|
+
return date.toISOString()
|
|
76
|
+
case 'relative':
|
|
77
|
+
return formatRelative(date, now ?? Date.now())
|
|
78
|
+
case 'epoch':
|
|
79
|
+
return String(Math.floor(date.getTime() / 1000))
|
|
80
|
+
case 'time-local':
|
|
81
|
+
default:
|
|
82
|
+
return date.toLocaleTimeString('en-US', {
|
|
83
|
+
hour12: false,
|
|
84
|
+
hour: '2-digit',
|
|
85
|
+
minute: '2-digit',
|
|
86
|
+
second: '2-digit',
|
|
87
|
+
})
|
|
88
|
+
}
|
|
24
89
|
}
|
|
25
90
|
|
|
26
91
|
/** Map a detected LogLevel to a Tailwind color class. */
|
|
@@ -294,9 +359,9 @@ export function parseLogRange(logRange: string): { tailLines?: number; sinceSeco
|
|
|
294
359
|
// Syntax highlight colors shared between JSON and logfmt rendering
|
|
295
360
|
export const SYNTAX_COLOR_KEY = '#7cacf8'
|
|
296
361
|
export const SYNTAX_COLOR_STRING = '#73c991'
|
|
297
|
-
const SYNTAX_COLOR_NUMBER = '#e5c07b'
|
|
298
|
-
const SYNTAX_COLOR_BOOLEAN = '#c678dd'
|
|
299
|
-
const SYNTAX_COLOR_NULL = '#808080'
|
|
362
|
+
export const SYNTAX_COLOR_NUMBER = '#e5c07b'
|
|
363
|
+
export const SYNTAX_COLOR_BOOLEAN = '#c678dd'
|
|
364
|
+
export const SYNTAX_COLOR_NULL = '#808080'
|
|
300
365
|
|
|
301
366
|
/**
|
|
302
367
|
* Syntax-highlight a pretty-printed JSON string for HTML display.
|