@skyhook-io/k8s-ui 1.14.12 → 1.16.0

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.
Files changed (72) hide show
  1. package/package.json +3 -3
  2. package/src/components/audit/AuditFindingsTable.tsx +1 -1
  3. package/src/components/checks/ChecksView.tsx +8 -8
  4. package/src/components/compare/CompareResourcePicker.tsx +4 -3
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
  6. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
  7. package/src/components/gitops/RollbackDialog.tsx +4 -3
  8. package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
  9. package/src/components/gitops/health-provenance.test.ts +1 -1
  10. package/src/components/gitops/index.ts +1 -0
  11. package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
  12. package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
  13. package/src/components/issues/IssuesView.tsx +2 -2
  14. package/src/components/logs/LogCore.tsx +131 -97
  15. package/src/components/logs/StructuredLogLine.tsx +18 -28
  16. package/src/components/logs/log-palette.ts +4 -0
  17. package/src/components/logs/useLogBuffer.ts +8 -39
  18. package/src/components/logs/useLogSearch.ts +52 -24
  19. package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
  20. package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
  21. package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
  22. package/src/components/resources/KueueAdmissionSection.tsx +140 -51
  23. package/src/components/resources/ResourcesView.tsx +4 -0
  24. package/src/components/resources/printer-columns.test.ts +1 -1
  25. package/src/components/resources/renderers/JobRenderer.tsx +5 -1
  26. package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
  27. package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
  28. package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
  29. package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
  30. package/src/components/resources/renderers/PodRenderer.tsx +10 -2
  31. package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
  32. package/src/components/resources/renderers/PolicySection.tsx +10 -1
  33. package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
  34. package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
  35. package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
  36. package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
  37. package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
  38. package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
  39. package/src/components/resources/renderers/index.ts +2 -0
  40. package/src/components/resources/resource-utils-kueue.ts +14 -0
  41. package/src/components/resources/resource-utils-ray.ts +14 -4
  42. package/src/components/shared/CreateResourceDialog.tsx +6 -2
  43. package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
  44. package/src/components/shared/DrainPlanDialog.tsx +14 -3
  45. package/src/components/shared/ResourceActionsBar.tsx +18 -11
  46. package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
  47. package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
  48. package/src/components/shared/SetImageDialog.tsx +4 -2
  49. package/src/components/shared/revision-role-badges.test.ts +1 -1
  50. package/src/components/trace/reachGraphModel.test.ts +1 -0
  51. package/src/components/trace/reachInspector.test.ts +4 -9
  52. package/src/components/ui/ConfirmDialog.tsx +5 -3
  53. package/src/components/ui/DialogPortal.test.tsx +279 -0
  54. package/src/components/ui/DialogPortal.tsx +147 -9
  55. package/src/components/ui/FilterPill.tsx +1 -1
  56. package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
  57. package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
  58. package/src/components/ui/RadarUpgradeNote.tsx +129 -0
  59. package/src/components/ui/ResourceBar.tsx +12 -6
  60. package/src/components/ui/SelectMenu.tsx +62 -19
  61. package/src/components/ui/YamlReview.tsx +4 -1
  62. package/src/components/ui/drawer-components.test.tsx +14 -1
  63. package/src/components/ui/drawer-components.tsx +50 -40
  64. package/src/components/ui/index.ts +8 -0
  65. package/src/components/workload/WorkloadView.tsx +109 -3
  66. package/src/types/core.ts +24 -4
  67. package/src/types/fetch-error.ts +33 -0
  68. package/src/utils/log-format.ts +1 -1
  69. package/src/utils/log-level.test.ts +269 -0
  70. package/src/utils/log-level.ts +301 -0
  71. package/src/utils/resource-hierarchy.test.ts +69 -0
  72. package/src/utils/resource-hierarchy.ts +87 -52
@@ -1,8 +1,8 @@
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, FileDown, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, ChevronRight, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, Braces, Palette, ListCollapse, Sun, Moon } from 'lucide-react'
3
+ import { Play, Square, Download, FileDown, Search, X, Terminal, RotateCcw, ChevronUp, ChevronDown, ChevronRight, CaseSensitive, Regex, WrapText, Clock, Copy, Trash2, Filter, EyeOff, Highlighter, Braces, Palette, ListCollapse, Sun, Moon } from 'lucide-react'
4
4
  import type { LogEntry, LogLevel } from './useLogBuffer'
5
- import { useLogSearch } from './useLogSearch'
5
+ import { useLogSearch, type LogSearchMode } from './useLogSearch'
6
6
  import { StructuredLogLine } from './StructuredLogLine'
7
7
  import { Tooltip } from '../ui/Tooltip'
8
8
  import { Input } from '../ui/Input'
@@ -16,6 +16,7 @@ import {
16
16
  TIMESTAMP_FORMAT_LABELS,
17
17
  } from '../../utils/log-format'
18
18
  import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
19
+ import { associateContinuations, groupContinuations, type LogGroup } from '../../utils/log-level'
19
20
  import { copyText } from '../../utils/clipboard'
20
21
  import { useAnimatedUnmount } from '../../hooks/useAnimatedUnmount'
21
22
  import { TRANSITION_MENU, overlayExitMs, overlayTransitionStyle } from '../../utils/animation'
@@ -71,13 +72,16 @@ interface LogCoreProps {
71
72
  interface LevelOption {
72
73
  level: LogLevel
73
74
  label: string
75
+ /** What the chip's lines are called in its tooltip */
76
+ noun: string
74
77
  }
75
78
 
76
79
  const LEVEL_OPTIONS: LevelOption[] = [
77
- { level: 'error', label: 'ERR' },
78
- { level: 'warn', label: 'WARN' },
79
- { level: 'info', label: 'INFO' },
80
- { level: 'debug', label: 'DBG' },
80
+ { level: 'error', label: 'ERR', noun: 'error logs' },
81
+ { level: 'warn', label: 'WARN', noun: 'warning logs' },
82
+ { level: 'info', label: 'INFO', noun: 'info logs' },
83
+ { level: 'debug', label: 'DBG', noun: 'debug logs' },
84
+ { level: 'unknown', label: 'OTHER', noun: 'lines with no level' },
81
85
  ]
82
86
 
83
87
  function getLevelActiveColor(level: LogLevel, palette: LogPalette): string {
@@ -86,7 +90,7 @@ function getLevelActiveColor(level: LogLevel, palette: LogPalette): string {
86
90
  case 'warn': return palette.levelActiveWarn
87
91
  case 'info': return palette.levelActiveInfo
88
92
  case 'debug': return palette.levelActiveDebug
89
- default: return palette.levelActiveDebug
93
+ default: return palette.levelActiveOther
90
94
  }
91
95
  }
92
96
 
@@ -121,19 +125,6 @@ const TIMESTAMP_FORMAT_SHORT_LABELS: Record<TimestampFormat, string> = {
121
125
  'epoch': 'Unix time',
122
126
  }
123
127
 
124
- function isContinuationLine(content: string): boolean {
125
- // Lines starting with whitespace are the dominant stack-trace continuation pattern:
126
- // Java `\tat com.foo.Bar`, Go `\tpackage.func`, Node ` at func`, Python ` File "..."`.
127
- if (/^\s/.test(content)) return true
128
- // Java's secondary chain markers that don't start with whitespace.
129
- return /^(Caused by:|Suppressed:|\.\.\. \d+ more)/.test(content)
130
- }
131
-
132
- interface LogGroup {
133
- head: LogEntry
134
- continuations: LogEntry[]
135
- }
136
-
137
128
  const TIP_DELAY = 150
138
129
 
139
130
  export function LogCore({
@@ -201,7 +192,7 @@ export function LogCore({
201
192
  try { return localStorage.getItem('radar-logs-collapse-stacks') !== 'false' } catch { return true }
202
193
  })
203
194
  const [enabledLevels, setEnabledLevels] = useState<Set<LogLevel>>(
204
- new Set(['error', 'warn', 'info', 'debug'])
195
+ new Set(['error', 'warn', 'info', 'debug', 'unknown'])
205
196
  )
206
197
  const [showDownloadMenu, setShowDownloadMenu] = useState(false)
207
198
  const downloadMenu = useAnimatedUnmount(showDownloadMenu, overlayExitMs('menu'))
@@ -232,32 +223,50 @@ export function LogCore({
232
223
  return () => clearInterval(id)
233
224
  }, [tsFormat, showTimestamps])
234
225
 
235
- // Level-filtered entries
236
- // 'unknown' logs are shown when all 4 known levels are enabled (no active filtering)
226
+ // One membership rule for filtering, counting and grouping: a stack-trace
227
+ // line belongs to the record its own pod and container started.
228
+ const association = useMemo(() => {
229
+ const { headOf, effectiveLevel } = associateContinuations(entries)
230
+ const headIdById = new Map<number, number>()
231
+ for (let i = 0; i < entries.length; i++) {
232
+ if (headOf[i] !== i) headIdById.set(entries[i].id, entries[headOf[i]].id)
233
+ }
234
+ return { headOf, effectiveLevel, headIdById }
235
+ }, [entries])
236
+
237
+ // Frames take their record's level so they filter and render as part of it.
238
+ const recordEntries = useMemo(
239
+ () => entries.map((e, i) => (association.effectiveLevel[i] === e.level ? e : { ...e, level: association.effectiveLevel[i] })),
240
+ [entries, association],
241
+ )
242
+
237
243
  const levelFilteredEntries = useMemo(() => {
238
244
  const allEnabled = LEVEL_OPTIONS.every(opt => enabledLevels.has(opt.level))
239
- if (allEnabled) return entries
240
- return entries.filter(e => enabledLevels.has(e.level))
241
- }, [entries, enabledLevels])
245
+ if (allEnabled) return recordEntries
246
+ return recordEntries.filter(e => enabledLevels.has(e.level))
247
+ }, [recordEntries, enabledLevels])
242
248
 
243
- // Level counts for badges
249
+ // Chip counts count records, so a 40-frame stack trace is one error.
244
250
  const levelCounts = useMemo(() => {
245
251
  const counts: Record<LogLevel, number> = { error: 0, warn: 0, info: 0, debug: 0, unknown: 0 }
246
- for (const e of entries) {
247
- counts[e.level]++
252
+ for (let i = 0; i < entries.length; i++) {
253
+ if (association.headOf[i] === i) counts[entries[i].level]++
248
254
  }
249
255
  return counts
250
- }, [entries])
256
+ }, [entries, association])
251
257
 
252
258
  const hasStructuredEntries = useMemo(() => entries.some(e => e.isJson || e.isLogfmt), [entries])
253
259
 
254
- // Search
255
- const search = useLogSearch(levelFilteredEntries, virtuosoRef)
260
+ const recordIdOf = useCallback(
261
+ (e: LogEntry) => association.headIdById.get(e.id) ?? e.id,
262
+ [association],
263
+ )
264
+ const search = useLogSearch(levelFilteredEntries, virtuosoRef, recordIdOf)
256
265
 
257
- // Display entries: use search-filtered when filter mode is active
258
- const displayEntries = search.isFilterMode && search.query
259
- ? search.filteredEntries
260
- : levelFilteredEntries
266
+ const displayEntries = search.isFiltering ? search.filteredEntries : levelFilteredEntries
267
+ const hiddenCount = levelFilteredEntries.length - displayEntries.length
268
+ // Hidden lines leave nothing to highlight, so Hide keeps normal rendering and stack grouping.
269
+ const highlightQuery = search.mode === 'hide' ? '' : search.query
261
270
 
262
271
  const exportTriggerRef = useRef<HTMLButtonElement>(null)
263
272
  const closeExportMenu = useCallback(() => {
@@ -464,12 +473,12 @@ export function LogCore({
464
473
  }, [])
465
474
 
466
475
  // Clicking a filter chip in a structured log value pushes the value into the
467
- // log search and enables filter mode so only matching lines are shown.
476
+ // log search and shows only the lines that match it.
468
477
  const handleFilterValue = useCallback((value: string) => {
469
478
  search.setQuery(value)
470
479
  // Values often contain regex metacharacters — force literal-substring matching.
471
480
  search.setIsRegex(false)
472
- search.setFilterMode(true)
481
+ search.setMode('only')
473
482
  if (!search.isOpen) search.open()
474
483
  }, [search])
475
484
 
@@ -486,28 +495,19 @@ export function LogCore({
486
495
  }, [])
487
496
 
488
497
  // Highlight set for current match
489
- const currentHighlightId = search.matchIndices.length > 0
490
- ? (search.isFilterMode
491
- ? search.filteredEntries[search.currentMatch]?.id
492
- : levelFilteredEntries[search.matchIndices[search.currentMatch]]?.id)
493
- : -1
494
-
495
- // Group stack-trace continuation lines under their preceding head line.
496
- // Disabled while search is active so matches inside continuations remain visible.
498
+ const currentHighlightId = search.matchIndices.length === 0 || search.mode === 'hide'
499
+ ? -1
500
+ : levelFilteredEntries[search.matchIndices[search.currentMatch]]?.id
501
+
502
+ // Group stack-trace lines under the line that started their record. Disabled
503
+ // while highlighting so matches inside stack frames remain visible. A line
504
+ // whose record start is filtered out stays on its own row.
497
505
  const groupedEntries = useMemo<LogGroup[]>(() => {
498
- if (!collapseStacks || search.query) {
506
+ if (!collapseStacks || highlightQuery) {
499
507
  return displayEntries.map(e => ({ head: e, continuations: [] }))
500
508
  }
501
- const groups: LogGroup[] = []
502
- for (const entry of displayEntries) {
503
- if (groups.length > 0 && isContinuationLine(entry.content)) {
504
- groups[groups.length - 1].continuations.push(entry)
505
- } else {
506
- groups.push({ head: entry, continuations: [] })
507
- }
508
- }
509
- return groups
510
- }, [displayEntries, collapseStacks, search.query])
509
+ return groupContinuations(displayEntries, association.headIdById)
510
+ }, [displayEntries, collapseStacks, highlightQuery, association])
511
511
 
512
512
  const scrollToBottom = useCallback(() => {
513
513
  virtuosoRef.current?.scrollToIndex({
@@ -584,7 +584,7 @@ export function LogCore({
584
584
  const active = enabledLevels.has(opt.level)
585
585
  const count = levelCounts[opt.level]
586
586
  return (
587
- <Tooltip key={opt.level} content={`${active ? 'Hide' : 'Show'} ${opt.label} logs`} delay={TIP_DELAY} position="bottom">
587
+ <Tooltip key={opt.level} content={`${active ? 'Hide' : 'Show'} ${opt.noun}`} delay={TIP_DELAY} position="bottom">
588
588
  <button
589
589
  onClick={() => toggleLevel(opt.level)}
590
590
  className={`px-1.5 py-0.5 text-[10px] font-medium rounded border transition-colors ${
@@ -876,7 +876,7 @@ export function LogCore({
876
876
 
877
877
  {/* Search bar */}
878
878
  {search.isOpen && (
879
- <div className={`flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
879
+ <div className={`@container/logsearch flex items-center gap-2 px-3 py-2 border-b ${palette.border} ${palette.toolbarBgMuted}`}>
880
880
  <Search className={`w-4 h-4 ${palette.textSecondary} shrink-0`} />
881
881
  <Input
882
882
  value={search.query}
@@ -921,47 +921,43 @@ export function LogCore({
921
921
  </button>
922
922
  </Tooltip>
923
923
 
924
- {/* Filter mode toggle */}
925
- <Tooltip content={search.isFilterMode ? 'Highlight mode' : 'Filter mode'} delay={TIP_DELAY} position="bottom">
926
- <button
927
- onClick={search.toggleFilterMode}
928
- className={`p-1 rounded transition-colors ${
929
- search.isFilterMode ? palette.toolbarActive : `${palette.textTertiary} ${palette.hoverText}`
930
- }`}
931
- >
932
- <Filter className="w-3.5 h-3.5" />
933
- </button>
934
- </Tooltip>
924
+ <SearchModeControl mode={search.mode} onChange={search.setMode} palette={palette} />
935
925
 
936
926
  {search.query && (
937
927
  <>
938
928
  <span className={`text-xs whitespace-nowrap ${search.regexError ? palette.textError : palette.textTertiary}`}>
939
929
  {search.regexError
940
930
  ? 'Invalid regex'
941
- : search.matchCount > 0
942
- ? `${search.currentMatch + 1} / ${search.matchCount}`
943
- : '0 results'}
931
+ : search.mode === 'hide'
932
+ ? `${hiddenCount.toLocaleString()} hidden`
933
+ : search.matchCount > 0
934
+ ? `${search.currentMatch + 1} / ${search.matchCount}`
935
+ : '0 results'}
944
936
  </span>
945
937
 
946
- {/* Navigation arrows */}
947
- <Tooltip content="Previous (Shift+Enter)" delay={TIP_DELAY} position="bottom">
948
- <button
949
- onClick={search.goToPrev}
950
- disabled={search.matchCount === 0}
951
- className={`p-1 rounded ${palette.textSecondary} ${palette.hoverText} disabled:opacity-30`}
952
- >
953
- <ChevronUp className="w-3.5 h-3.5" />
954
- </button>
955
- </Tooltip>
956
- <Tooltip content="Next (Enter)" delay={TIP_DELAY} position="bottom">
957
- <button
958
- onClick={search.goToNext}
959
- disabled={search.matchCount === 0}
960
- className={`p-1 rounded ${palette.textSecondary} ${palette.hoverText} disabled:opacity-30`}
961
- >
962
- <ChevronDown className="w-3.5 h-3.5" />
963
- </button>
964
- </Tooltip>
938
+ {/* Matches are off screen in Hide mode, so there is nothing to step through */}
939
+ {search.mode !== 'hide' && (
940
+ <>
941
+ <Tooltip content="Previous (Shift+Enter)" delay={TIP_DELAY} position="bottom">
942
+ <button
943
+ onClick={search.goToPrev}
944
+ disabled={search.matchCount === 0}
945
+ className={`p-1 rounded ${palette.textSecondary} ${palette.hoverText} disabled:opacity-30`}
946
+ >
947
+ <ChevronUp className="w-3.5 h-3.5" />
948
+ </button>
949
+ </Tooltip>
950
+ <Tooltip content="Next (Enter)" delay={TIP_DELAY} position="bottom">
951
+ <button
952
+ onClick={search.goToNext}
953
+ disabled={search.matchCount === 0}
954
+ className={`p-1 rounded ${palette.textSecondary} ${palette.hoverText} disabled:opacity-30`}
955
+ >
956
+ <ChevronDown className="w-3.5 h-3.5" />
957
+ </button>
958
+ </Tooltip>
959
+ </>
960
+ )}
965
961
 
966
962
  <button
967
963
  onClick={() => search.setQuery('')}
@@ -990,8 +986,8 @@ export function LogCore({
990
986
  ) : groupedEntries.length === 0 ? (
991
987
  <div className={`${EMPTY_STATE_CLASS} ${palette.textTertiary}`}>
992
988
  <Terminal className="w-8 h-8" />
993
- <span>{emptyMessage}</span>
994
- {emptyCommand && (
989
+ <span>{entries.length > 0 ? `Filters hide all ${entries.length.toLocaleString()} loaded lines` : emptyMessage}</span>
990
+ {entries.length === 0 && emptyCommand && (
995
991
  <button type="button" onClick={() => { void copyText(emptyCommand) }} className={`mt-2 inline-flex max-w-[80%] items-center gap-2 rounded border px-3 py-2 font-mono text-xs ${palette.border} ${palette.toolbarBg}`} title="Copy recovery command">
996
992
  <code className="truncate">{emptyCommand}</code>
997
993
  <Copy className="h-3.5 w-3.5 shrink-0" />
@@ -1011,7 +1007,7 @@ export function LogCore({
1011
1007
  itemContent={(_index, group) => (
1012
1008
  <LogGroupItem
1013
1009
  group={group}
1014
- searchQuery={search.query}
1010
+ searchQuery={highlightQuery}
1015
1011
  searchIsRegex={search.isRegex}
1016
1012
  searchIsCaseSensitive={search.isCaseSensitive}
1017
1013
  showPodName={showPodName}
@@ -1047,14 +1043,52 @@ export function LogCore({
1047
1043
  <div className={`flex items-center gap-4 px-3 py-1 border-t ${palette.border} ${palette.toolbarBg} text-[10px] ${palette.textDisabled}`}>
1048
1044
  {onStartStream && <Shortcut keys="S" label={isStreaming ? 'Stop stream' : 'Stream'} palette={palette} />}
1049
1045
  <Shortcut keys="Ctrl+F" label="Search" palette={palette} />
1050
- <Shortcut keys="Enter" label="Next match" palette={palette} />
1051
- <Shortcut keys="Shift+Enter" label="Prev match" palette={palette} />
1046
+ {search.mode !== 'hide' && (
1047
+ <>
1048
+ <Shortcut keys="Enter" label="Next match" palette={palette} />
1049
+ <Shortcut keys="Shift+Enter" label="Prev match" palette={palette} />
1050
+ </>
1051
+ )}
1052
1052
  <Shortcut keys="Esc" label="Close search" palette={palette} />
1053
1053
  </div>
1054
1054
  </div>
1055
1055
  )
1056
1056
  }
1057
1057
 
1058
+ const SEARCH_MODES: { mode: LogSearchMode; label: string; tip: string; Icon: typeof Filter }[] = [
1059
+ { mode: 'highlight', label: 'Highlight', tip: 'Show all lines and highlight matches', Icon: Highlighter },
1060
+ { mode: 'only', label: 'Only matching', tip: 'Show only matching lines, with their stack traces', Icon: Filter },
1061
+ { mode: 'hide', label: 'Hide matching', tip: 'Hide matching lines and their stack traces, like grep -v', Icon: EyeOff },
1062
+ ]
1063
+
1064
+ /** What the search query does to the list. Labels collapse to icons when the search row is narrow. */
1065
+ function SearchModeControl({ mode, onChange, palette }: {
1066
+ mode: LogSearchMode
1067
+ onChange: (mode: LogSearchMode) => void
1068
+ palette: LogPalette
1069
+ }) {
1070
+ return (
1071
+ <div role="radiogroup" aria-label="Search mode" className={`flex items-center shrink-0 rounded border ${palette.border}`}>
1072
+ {SEARCH_MODES.map(({ mode: m, label, tip, Icon }) => (
1073
+ <Tooltip key={m} content={tip} delay={TIP_DELAY} position="bottom">
1074
+ <button
1075
+ role="radio"
1076
+ aria-checked={mode === m}
1077
+ aria-label={label}
1078
+ onClick={() => onChange(m)}
1079
+ className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] whitespace-nowrap transition-colors ${
1080
+ mode === m ? palette.toolbarActive : `${palette.textTertiary} ${palette.hoverText}`
1081
+ }`}
1082
+ >
1083
+ <Icon className="w-3.5 h-3.5" />
1084
+ <span className="hidden @xl/logsearch:inline">{label}</span>
1085
+ </button>
1086
+ </Tooltip>
1087
+ ))}
1088
+ </div>
1089
+ )
1090
+ }
1091
+
1058
1092
  /** One pill in the export popover's scope and format rows. */
1059
1093
  function ExportChoice({ active, onClick, palette, children }: {
1060
1094
  active: boolean
@@ -1,6 +1,7 @@
1
1
  import { useState, useMemo } from 'react'
2
2
  import { ChevronRight, ChevronDown, Filter } from 'lucide-react'
3
3
  import type { LogLevel } from './useLogBuffer'
4
+ import { selectLevelField } from '../../utils/log-level'
4
5
  import { unescapeJsonStrings, parseLogfmt } from '../../utils/log-format'
5
6
  import { getLogPalette, getLogLevelColor, type LogPalette } from './log-palette'
6
7
 
@@ -65,7 +66,7 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
65
66
  className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}
66
67
  >
67
68
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
68
- <SummaryLine obj={parsed} palette={palette} />
69
+ <SummaryLine obj={parsed} level={level} palette={palette} />
69
70
  <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
70
71
  </span>
71
72
  ) : (
@@ -76,7 +77,7 @@ export function StructuredLogLine({ content, level, wordWrap, isLogfmt, defaultE
76
77
  className={`cursor-pointer ${palette.hoverSurface} rounded px-0.5 -ml-0.5`}
77
78
  >
78
79
  <span className="inline-flex items-center align-middle mr-0.5">{chevron}</span>
79
- <SummaryLine obj={parsed} palette={palette} />
80
+ <SummaryLine obj={parsed} level={level} palette={palette} />
80
81
  <span className={`${palette.textTertiary} ml-1`}>{`{${fieldCount} fields}`}</span>
81
82
  </span>
82
83
  <span className={`block ml-4 ${wordWrap ? 'whitespace-pre-wrap break-all' : 'whitespace-pre'}`}>
@@ -167,8 +168,8 @@ function JsonExpanded({ text, onFilterValue, palette }: { text: string; onFilter
167
168
  return <>{nodes}</>
168
169
  }
169
170
 
170
- function SummaryLine({ obj, palette }: { obj: Record<string, unknown>; palette: LogPalette }) {
171
- const lvl = obj.level ?? obj.severity ?? obj.lvl ?? nestedField(obj, 'log', 'level')
171
+ function SummaryLine({ obj, level, palette }: { obj: Record<string, unknown>; level: LogLevel; palette: LogPalette }) {
172
+ const lvl = selectLevelField(obj)?.raw
172
173
  const msg = obj.msg ?? obj.message
173
174
  const rawErr = obj.error ?? obj.err
174
175
  const err = typeof rawErr === 'string'
@@ -179,8 +180,8 @@ function SummaryLine({ obj, palette }: { obj: Record<string, unknown>; palette:
179
180
  return (
180
181
  <>
181
182
  {lvl != null && (
182
- <span className={`${getLevelBadgeColor(lvl, palette)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
183
- {formatLevel(lvl)}
183
+ <span className={`${levelBadgeColor(level, palette)} text-[10px] font-semibold px-1 py-px rounded mr-1.5 inline-block`}>
184
+ {formatLevel(lvl, level)}
184
185
  </span>
185
186
  )}
186
187
  {typeof msg === 'string' && (
@@ -221,30 +222,19 @@ function nestedField(obj: Record<string, unknown>, parent: string, child: string
221
222
  return undefined
222
223
  }
223
224
 
224
- function formatLevel(lvl: unknown): string {
225
- if (typeof lvl === 'number') {
226
- if (lvl >= 50) return 'ERR'
227
- if (lvl >= 40) return 'WARN'
228
- if (lvl >= 30) return 'INFO'
229
- return 'DBG'
230
- }
225
+ const NUMERIC_LEVEL_LABELS: Record<LogLevel, string> = { error: 'ERR', warn: 'WARN', info: 'INFO', debug: 'DBG', unknown: '?' }
226
+
227
+ function formatLevel(lvl: unknown, level: LogLevel): string {
228
+ if (typeof lvl === 'number') return NUMERIC_LEVEL_LABELS[level]
231
229
  return String(lvl).toUpperCase()
232
230
  }
233
231
 
234
- function getLevelBadgeColor(lvl: unknown, palette: LogPalette): string {
235
- let normalized: string
236
- if (typeof lvl === 'number') {
237
- // Pino/bunyan numeric levels: 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
238
- if (lvl >= 50) normalized = 'error'
239
- else if (lvl >= 40) normalized = 'warn'
240
- else if (lvl >= 30) normalized = 'info'
241
- else normalized = 'debug'
242
- } else {
243
- normalized = String(lvl).toLowerCase()
232
+ function levelBadgeColor(level: LogLevel, palette: LogPalette): string {
233
+ switch (level) {
234
+ case 'error': return palette.levelBadgeError
235
+ case 'warn': return palette.levelBadgeWarn
236
+ case 'info': return palette.levelBadgeInfo
237
+ case 'debug': return palette.levelBadgeDebug
238
+ default: return palette.levelBadgeNeutral
244
239
  }
245
- if (/^(error|err|fatal|panic|critical|crit)$/.test(normalized)) return palette.levelBadgeError
246
- if (/^(warn|warning)$/.test(normalized)) return palette.levelBadgeWarn
247
- if (/^(info|information|notice)$/.test(normalized)) return palette.levelBadgeInfo
248
- if (/^(debug|dbg|trace|verbose)$/.test(normalized)) return palette.levelBadgeDebug
249
- return palette.levelBadgeNeutral
250
240
  }
@@ -53,6 +53,8 @@ export interface LogPalette {
53
53
  levelActiveWarn: string
54
54
  levelActiveInfo: string
55
55
  levelActiveDebug: string
56
+ /** Lines with no recognizable level */
57
+ levelActiveOther: string
56
58
 
57
59
  // Level-badge colors used inside StructuredLogLine
58
60
  levelBadgeError: string
@@ -110,6 +112,7 @@ const DARK_PALETTE: LogPalette = {
110
112
  levelActiveWarn: 'bg-amber-500/20 text-amber-400 border-amber-500/40',
111
113
  levelActiveInfo: 'bg-blue-500/20 text-blue-400 border-blue-500/40',
112
114
  levelActiveDebug: 'bg-slate-700 text-slate-300 border-slate-600',
115
+ levelActiveOther: 'bg-transparent text-slate-300 border-slate-500',
113
116
 
114
117
  levelBadgeError: 'bg-red-500/20 text-red-400 border border-red-500/40',
115
118
  levelBadgeWarn: 'bg-amber-500/20 text-amber-400 border border-amber-500/40',
@@ -166,6 +169,7 @@ const LIGHT_PALETTE: LogPalette = {
166
169
  levelActiveWarn: 'bg-amber-100 text-amber-700 border-amber-400',
167
170
  levelActiveInfo: 'bg-blue-100 text-blue-700 border-blue-400',
168
171
  levelActiveDebug: 'bg-slate-200 text-slate-700 border-slate-400',
172
+ levelActiveOther: 'bg-white text-slate-700 border-slate-400',
169
173
 
170
174
  levelBadgeError: 'bg-red-100 text-red-700 border border-red-400',
171
175
  levelBadgeWarn: 'bg-amber-100 text-amber-700 border border-amber-400',
@@ -1,7 +1,9 @@
1
1
  import { useState, useRef, useCallback } from 'react'
2
2
  import { isLogfmt } from '../../utils/log-format'
3
+ import { detectLevel, type LevelSource, type LogLevel } from '../../utils/log-level'
3
4
 
4
- export type LogLevel = 'error' | 'warn' | 'info' | 'debug' | 'unknown'
5
+ export type { LevelSource, LogLevel }
6
+ export { detectLogLevel } from '../../utils/log-level'
5
7
 
6
8
  export interface LogEntry {
7
9
  sourceLabel?: string
@@ -17,54 +19,19 @@ export interface LogEntry {
17
19
  */
18
20
  podColorIndex?: number
19
21
  level: LogLevel
22
+ levelSource: LevelSource
20
23
  isJson: boolean
21
24
  isLogfmt: boolean
22
25
  }
23
26
 
24
27
  const MAX_BUFFER_SIZE = 10_000
25
28
 
26
- /**
27
- * Detect log level from content using word-boundary matching.
28
- * For JSON logs, prefer the `level`, `severity`, or `lvl` field.
29
- */
30
- export function detectLogLevel(content: string): LogLevel {
31
- // Fast path for JSON: check level/severity field
32
- const trimmed = content.trimStart()
33
- if (trimmed[0] === '{') {
34
- try {
35
- const obj = JSON.parse(trimmed)
36
- const rawLevel = obj.level ?? obj.severity ?? obj.lvl ?? ''
37
- // Numeric levels (pino/bunyan): 10=trace, 20=debug, 30=info, 40=warn, 50=error, 60=fatal
38
- if (typeof rawLevel === 'number') {
39
- if (rawLevel >= 50) return 'error'
40
- if (rawLevel >= 40) return 'warn'
41
- if (rawLevel >= 30) return 'info'
42
- return 'debug'
43
- }
44
- const lvl = String(rawLevel).toLowerCase()
45
- if (/^(error|err|fatal|panic|critical|crit)$/.test(lvl)) return 'error'
46
- if (/^(warn|warning)$/.test(lvl)) return 'warn'
47
- if (/^(info|information|notice)$/.test(lvl)) return 'info'
48
- if (/^(debug|trace|verbose)$/.test(lvl)) return 'debug'
49
- } catch {
50
- // Not valid JSON, fall through to text matching
51
- }
52
- }
53
-
54
- const lower = content.toLowerCase()
55
- if (/\b(error|fatal|panic|critical|crit|exception)\b/.test(lower)) return 'error'
56
- if (/\b(warn|warning)\b/.test(lower)) return 'warn'
57
- if (/\b(debug|trace)\b/.test(lower)) return 'debug'
58
- if (/\b(info)\b/.test(lower)) return 'info'
59
- return 'unknown'
60
- }
61
-
62
29
  function isJsonContent(content: string): boolean {
63
30
  const trimmed = content.trimStart()
64
31
  return trimmed[0] === '{' && trimmed[trimmed.length - 1] === '}'
65
32
  }
66
33
 
67
- type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'isJson' | 'isLogfmt'>
34
+ type RawLogEntry = Omit<LogEntry, 'id' | 'level' | 'levelSource' | 'isJson' | 'isLogfmt'>
68
35
 
69
36
  interface UseLogBufferReturn {
70
37
  entries: LogEntry[]
@@ -82,10 +49,12 @@ export function useLogBuffer(): UseLogBufferReturn {
82
49
 
83
50
  const enrichEntry = useCallback((raw: RawLogEntry): LogEntry => {
84
51
  const isJ = isJsonContent(raw.content)
52
+ const detected = detectLevel(raw.content)
85
53
  return {
86
54
  ...raw,
87
55
  id: idCounter.current++,
88
- level: detectLogLevel(raw.content),
56
+ level: detected.level,
57
+ levelSource: detected.source,
89
58
  isJson: isJ,
90
59
  isLogfmt: !isJ && isLogfmt(raw.content),
91
60
  }