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