@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.
- package/package.json +3 -3
- package/src/components/audit/AuditFindingsTable.tsx +1 -1
- package/src/components/checks/ChecksView.tsx +8 -8
- package/src/components/compare/CompareResourcePicker.tsx +4 -3
- package/src/components/gitops/GitOpsDetailLayout.tsx +3 -1
- package/src/components/gitops/GitOpsHealthSourceNotice.tsx +12 -2
- package/src/components/gitops/RollbackDialog.tsx +4 -3
- package/src/components/gitops/SyncOptionsDialog.tsx +4 -3
- package/src/components/gitops/health-provenance.test.ts +1 -1
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/ArgoResourceDiff.tsx +1 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +4 -1
- package/src/components/issues/IssuesView.tsx +2 -2
- package/src/components/logs/LogCore.tsx +131 -97
- package/src/components/logs/StructuredLogLine.tsx +18 -28
- package/src/components/logs/log-palette.ts +4 -0
- package/src/components/logs/useLogBuffer.ts +8 -39
- package/src/components/logs/useLogSearch.ts +52 -24
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +140 -0
- package/src/components/namespace-switcher/NamespacePicker.tsx +26 -20
- package/src/components/resources/KueueAdmissionSection.test.tsx +183 -6
- package/src/components/resources/KueueAdmissionSection.tsx +140 -51
- package/src/components/resources/ResourcesView.tsx +4 -0
- package/src/components/resources/printer-columns.test.ts +1 -1
- package/src/components/resources/renderers/JobRenderer.tsx +5 -1
- package/src/components/resources/renderers/JobSetRenderer.tsx +4 -1
- package/src/components/resources/renderers/KueueQueueNavigation.test.tsx +2 -2
- package/src/components/resources/renderers/KueueWorkloadRenderer.tsx +2 -15
- package/src/components/resources/renderers/PodRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/PodRenderer.tsx +10 -2
- package/src/components/resources/renderers/PolicySection.test.tsx +42 -0
- package/src/components/resources/renderers/PolicySection.tsx +10 -1
- package/src/components/resources/renderers/RayClusterRenderer.tsx +7 -7
- package/src/components/resources/renderers/RayJobRenderer.test.tsx +71 -0
- package/src/components/resources/renderers/RayJobRenderer.tsx +134 -0
- package/src/components/resources/renderers/ReflectorSection.test.tsx +4 -2
- package/src/components/resources/renderers/RolloutRenderer.test.tsx +1 -1
- package/src/components/resources/renderers/WorkloadRenderer.tsx +7 -3
- package/src/components/resources/renderers/index.ts +2 -0
- package/src/components/resources/resource-utils-kueue.ts +14 -0
- package/src/components/resources/resource-utils-ray.ts +14 -4
- package/src/components/shared/CreateResourceDialog.tsx +6 -2
- package/src/components/shared/DrainPlanDialog.test.tsx +10 -0
- package/src/components/shared/DrainPlanDialog.tsx +14 -3
- package/src/components/shared/ResourceActionsBar.tsx +18 -11
- package/src/components/shared/ResourceRendererDispatch.test.tsx +12 -5
- package/src/components/shared/ResourceRendererDispatch.tsx +15 -6
- package/src/components/shared/SetImageDialog.tsx +4 -2
- package/src/components/shared/revision-role-badges.test.ts +1 -1
- package/src/components/trace/reachGraphModel.test.ts +1 -0
- package/src/components/trace/reachInspector.test.ts +4 -9
- package/src/components/ui/ConfirmDialog.tsx +5 -3
- package/src/components/ui/DialogPortal.test.tsx +279 -0
- package/src/components/ui/DialogPortal.tsx +147 -9
- package/src/components/ui/FilterPill.tsx +1 -1
- package/src/components/ui/RadarUpgradeNote.click.test.tsx +24 -0
- package/src/components/ui/RadarUpgradeNote.test.tsx +66 -0
- package/src/components/ui/RadarUpgradeNote.tsx +129 -0
- package/src/components/ui/ResourceBar.tsx +12 -6
- package/src/components/ui/SelectMenu.tsx +62 -19
- package/src/components/ui/YamlReview.tsx +4 -1
- package/src/components/ui/drawer-components.test.tsx +14 -1
- package/src/components/ui/drawer-components.tsx +50 -40
- package/src/components/ui/index.ts +8 -0
- package/src/components/workload/WorkloadView.tsx +109 -3
- package/src/types/core.ts +24 -4
- package/src/types/fetch-error.ts +33 -0
- package/src/utils/log-format.ts +1 -1
- package/src/utils/log-level.test.ts +269 -0
- package/src/utils/log-level.ts +301 -0
- package/src/utils/resource-hierarchy.test.ts +69 -0
- 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.
|
|
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
|
-
//
|
|
236
|
-
//
|
|
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
|
|
240
|
-
return
|
|
241
|
-
}, [
|
|
245
|
+
if (allEnabled) return recordEntries
|
|
246
|
+
return recordEntries.filter(e => enabledLevels.has(e.level))
|
|
247
|
+
}, [recordEntries, enabledLevels])
|
|
242
248
|
|
|
243
|
-
//
|
|
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 (
|
|
247
|
-
counts[
|
|
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
|
-
|
|
255
|
-
|
|
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
|
-
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
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
|
|
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.
|
|
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
|
|
490
|
-
?
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
//
|
|
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 ||
|
|
506
|
+
if (!collapseStacks || highlightQuery) {
|
|
499
507
|
return displayEntries.map(e => ({ head: e, continuations: [] }))
|
|
500
508
|
}
|
|
501
|
-
|
|
502
|
-
|
|
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.
|
|
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={
|
|
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
|
-
|
|
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.
|
|
942
|
-
? `${
|
|
943
|
-
:
|
|
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
|
-
{/*
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
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={
|
|
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
|
-
|
|
1051
|
-
|
|
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 =
|
|
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={`${
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
|
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:
|
|
56
|
+
level: detected.level,
|
|
57
|
+
levelSource: detected.source,
|
|
89
58
|
isJson: isJ,
|
|
90
59
|
isLogfmt: !isJ && isLogfmt(raw.content),
|
|
91
60
|
}
|