@skyhook-io/k8s-ui 1.9.0 → 1.9.1
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 +1 -1
- package/src/components/logs/useLogSearch.ts +12 -7
- package/src/components/resources/ResourcesView.per-container.test.ts +111 -0
- package/src/components/resources/ResourcesView.tsx +301 -92
- package/src/components/resources/renderers/PodRenderer.tsx +4 -1
- package/src/components/timeline/TimelineList.tsx +7 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +9 -4
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDebouncedValue.ts +29 -0
- package/src/types/core.ts +17 -4
package/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { useState, useMemo, useCallback, useEffect, useRef } from 'react'
|
|
1
|
+
import { useState, useMemo, useCallback, useDeferredValue, useEffect, useRef } from 'react'
|
|
2
2
|
import type { VirtuosoHandle } from 'react-virtuoso'
|
|
3
3
|
import type { LogEntry } from './useLogBuffer'
|
|
4
4
|
import { stripAnsi, escapeRegExp } from '../../utils/log-format'
|
|
@@ -40,17 +40,22 @@ export function useLogSearch(
|
|
|
40
40
|
const [currentMatch, setCurrentMatch] = useState(0)
|
|
41
41
|
const [isOpen, setIsOpen] = useState(false)
|
|
42
42
|
|
|
43
|
+
// The scan strips ANSI and regex-tests every buffered line — on a multi-MB
|
|
44
|
+
// buffer that's too slow to run synchronously per keystroke. Deferring lets
|
|
45
|
+
// the input echo immediately while the match set catches up.
|
|
46
|
+
const deferredQuery = useDeferredValue(query)
|
|
47
|
+
|
|
43
48
|
const { matchIndices, regexError } = useMemo(() => {
|
|
44
|
-
if (!
|
|
49
|
+
if (!deferredQuery) {
|
|
45
50
|
return { matchIndices: [] as number[], regexError: null }
|
|
46
51
|
}
|
|
47
52
|
|
|
48
53
|
try {
|
|
49
54
|
let pattern: RegExp
|
|
50
55
|
if (isRegex) {
|
|
51
|
-
pattern = new RegExp(
|
|
56
|
+
pattern = new RegExp(deferredQuery, isCaseSensitive ? 'g' : 'gi')
|
|
52
57
|
} else {
|
|
53
|
-
pattern = new RegExp(escapeRegExp(
|
|
58
|
+
pattern = new RegExp(escapeRegExp(deferredQuery), isCaseSensitive ? 'g' : 'gi')
|
|
54
59
|
}
|
|
55
60
|
|
|
56
61
|
const indices: number[] = []
|
|
@@ -65,14 +70,14 @@ export function useLogSearch(
|
|
|
65
70
|
} catch (e) {
|
|
66
71
|
return { matchIndices: [] as number[], regexError: e instanceof Error ? e.message : 'Invalid regex' }
|
|
67
72
|
}
|
|
68
|
-
}, [entries,
|
|
73
|
+
}, [entries, deferredQuery, isRegex, isCaseSensitive])
|
|
69
74
|
|
|
70
75
|
// Filtered entries for filter mode
|
|
71
76
|
const filteredEntries = useMemo(() => {
|
|
72
|
-
if (!isFilterMode || !
|
|
77
|
+
if (!isFilterMode || !deferredQuery) return entries
|
|
73
78
|
const matchSet = new Set(matchIndices)
|
|
74
79
|
return entries.filter((_, i) => matchSet.has(i))
|
|
75
|
-
}, [entries, isFilterMode,
|
|
80
|
+
}, [entries, isFilterMode, deferredQuery, matchIndices])
|
|
76
81
|
|
|
77
82
|
// Reset current match when search criteria change (but not when new entries arrive during streaming)
|
|
78
83
|
const prevCriteria = useRef({ query, isRegex, isCaseSensitive })
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { ContainerResourceMetrics } from '../../types'
|
|
3
|
+
import { podAggregate, readContainer } from './ResourcesView'
|
|
4
|
+
|
|
5
|
+
// Build a CPU-only container fixture; memory fields default to 0.
|
|
6
|
+
function cpu(name: string, usage: number, request: number, limit: number): ContainerResourceMetrics {
|
|
7
|
+
return { name, cpu: usage, cpuRequest: request, cpuLimit: limit, memory: 0, memoryRequest: 0, memoryLimit: 0 }
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Build a memory-only container fixture; cpu fields default to 0.
|
|
11
|
+
function mem(name: string, usage: number, request: number, limit: number): ContainerResourceMetrics {
|
|
12
|
+
return { name, cpu: 0, cpuRequest: 0, cpuLimit: 0, memory: usage, memoryRequest: request, memoryLimit: limit }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('readContainer', () => {
|
|
16
|
+
it("uses limit as the yardstick when a limit is set", () => {
|
|
17
|
+
const r = readContainer(cpu('app', 50, 25, 100), 'cpu')
|
|
18
|
+
expect(r.yardstick).toBe('limit')
|
|
19
|
+
expect(r.denom).toBe(100)
|
|
20
|
+
expect(r.pct).toBe(50)
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('falls back to request when limit is 0 but request is set', () => {
|
|
24
|
+
const r = readContainer(cpu('app', 80, 100, 0), 'cpu')
|
|
25
|
+
expect(r.yardstick).toBe('request')
|
|
26
|
+
expect(r.denom).toBe(100)
|
|
27
|
+
expect(r.pct).toBe(80)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('allows request-only pct to exceed 100%', () => {
|
|
31
|
+
const r = readContainer(cpu('app', 200, 100, 0), 'cpu')
|
|
32
|
+
expect(r.yardstick).toBe('request')
|
|
33
|
+
expect(r.pct).toBe(200)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("reports 'none' when neither request nor limit is set", () => {
|
|
37
|
+
const r = readContainer(cpu('app', 40, 0, 0), 'cpu')
|
|
38
|
+
expect(r.yardstick).toBe('none')
|
|
39
|
+
expect(r.pct).toBe(-1)
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('reads the memory fields for the memory kind', () => {
|
|
43
|
+
const r = readContainer(mem('app', 64, 32, 128), 'memory')
|
|
44
|
+
expect(r.yardstick).toBe('limit')
|
|
45
|
+
expect(r.denom).toBe(128)
|
|
46
|
+
expect(r.pct).toBe(50)
|
|
47
|
+
})
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
describe('podAggregate', () => {
|
|
51
|
+
it("reports mode 'limit' with the summed limit and request marker when every container is limited", () => {
|
|
52
|
+
const containers = [
|
|
53
|
+
cpu('app', 30, 50, 100),
|
|
54
|
+
cpu('sidecar', 40, 50, 200),
|
|
55
|
+
]
|
|
56
|
+
const result = podAggregate(containers, 'cpu')
|
|
57
|
+
expect(result.mode).toBe('limit')
|
|
58
|
+
expect(result.totalUsage).toBe(70)
|
|
59
|
+
expect(result.denom).toBe(300) // summed limit
|
|
60
|
+
expect(result.markerPct).toBeCloseTo((100 / 300) * 100) // summed request / summed limit
|
|
61
|
+
expect(result.unlimitedCount).toBe(0)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("omits the marker in 'limit' mode when no container sets a request", () => {
|
|
65
|
+
const containers = [cpu('a', 30, 0, 100), cpu('b', 40, 0, 200)]
|
|
66
|
+
const result = podAggregate(containers, 'cpu')
|
|
67
|
+
expect(result.mode).toBe('limit')
|
|
68
|
+
expect(result.markerPct).toBeUndefined()
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it("reports mode 'partial' with usage and unbounded count when only some containers are limited", () => {
|
|
72
|
+
const containers = [
|
|
73
|
+
cpu('app', 80, 0, 0), // dominant, unbounded
|
|
74
|
+
cpu('sidecar', 5, 0, 100), // small, limited
|
|
75
|
+
]
|
|
76
|
+
const result = podAggregate(containers, 'cpu')
|
|
77
|
+
expect(result.mode).toBe('partial')
|
|
78
|
+
expect(result.totalUsage).toBe(85)
|
|
79
|
+
expect(result.denom).toBe(0) // a partial limit sum is not a real ceiling
|
|
80
|
+
expect(result.unlimitedCount).toBe(1)
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it("reports mode 'request' with the summed request when there are no limits but some requests", () => {
|
|
84
|
+
const containers = [
|
|
85
|
+
cpu('a', 50, 100, 0),
|
|
86
|
+
cpu('b', 40, 200, 0),
|
|
87
|
+
]
|
|
88
|
+
const result = podAggregate(containers, 'cpu')
|
|
89
|
+
expect(result.mode).toBe('request')
|
|
90
|
+
expect(result.totalUsage).toBe(90)
|
|
91
|
+
expect(result.denom).toBe(300) // summed request
|
|
92
|
+
expect(result.unlimitedCount).toBe(2)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it("reports mode 'none' when no container sets a request or limit", () => {
|
|
96
|
+
const containers = [cpu('a', 10, 0, 0), cpu('b', 20, 0, 0)]
|
|
97
|
+
const result = podAggregate(containers, 'cpu')
|
|
98
|
+
expect(result.mode).toBe('none')
|
|
99
|
+
expect(result.totalUsage).toBe(30)
|
|
100
|
+
expect(result.denom).toBe(0)
|
|
101
|
+
expect(result.unlimitedCount).toBe(2)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('aggregates the memory fields for the memory kind', () => {
|
|
105
|
+
const containers = [mem('a', 64, 32, 128), mem('b', 96, 64, 256)]
|
|
106
|
+
const result = podAggregate(containers, 'memory')
|
|
107
|
+
expect(result.mode).toBe('limit')
|
|
108
|
+
expect(result.totalUsage).toBe(160)
|
|
109
|
+
expect(result.denom).toBe(384) // summed memory limit
|
|
110
|
+
})
|
|
111
|
+
})
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import React, { useState, useMemo, useEffect, useCallback, useRef, useContext, useId } from 'react'
|
|
1
|
+
import React, { useState, useMemo, useEffect, useCallback, useDeferredValue, useRef, useContext, useId } from 'react'
|
|
2
2
|
import { TableVirtuoso, type TableVirtuosoHandle } from 'react-virtuoso'
|
|
3
|
+
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
|
3
4
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
4
5
|
import { RestrictedState } from '../ui/RestrictedState'
|
|
5
6
|
import { Input } from '../ui/Input'
|
|
6
|
-
import type { TopPodMetrics, TopNodeMetrics } from '../../types'
|
|
7
|
+
import type { TopPodMetrics, TopNodeMetrics, ContainerResourceMetrics } from '../../types'
|
|
7
8
|
import {
|
|
8
9
|
Search,
|
|
9
10
|
RefreshCw,
|
|
@@ -2185,6 +2186,15 @@ export function ResourcesView({
|
|
|
2185
2186
|
setBulkForceDelete(false)
|
|
2186
2187
|
}, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
2187
2188
|
const [searchTerm, setSearchTerm] = useState(initialFilters.search)
|
|
2189
|
+
// Typing must never wait on the list or the router. The input renders raw
|
|
2190
|
+
// searchTerm; filtering consumes the deferred copy (React yields to keep
|
|
2191
|
+
// keystrokes responsive on large lists); the URL write consumes the
|
|
2192
|
+
// debounced copy (one history entry per pause, not one per keystroke —
|
|
2193
|
+
// per-keystroke navigations re-render the whole route tree and, when
|
|
2194
|
+
// renders lag, the URL→state sync effect could revert in-flight input).
|
|
2195
|
+
// Clearing skips the debounce so the × cleans the URL immediately.
|
|
2196
|
+
const deferredSearchTerm = useDeferredValue(searchTerm)
|
|
2197
|
+
const debouncedSearchTerm = useDebouncedValue(searchTerm, 300, (v) => v === '')
|
|
2188
2198
|
const [regexMode, setRegexMode] = useState(initialFilters.regex)
|
|
2189
2199
|
const [sortColumn, setSortColumn] = useState<string | null>(null)
|
|
2190
2200
|
const [sortDirection, setSortDirection] = useState<SortDirection>(null)
|
|
@@ -2963,8 +2973,17 @@ export function ResourcesView({
|
|
|
2963
2973
|
setOwnerName(newFilters.ownerName)
|
|
2964
2974
|
}
|
|
2965
2975
|
|
|
2966
|
-
// Update search if it changed
|
|
2967
|
-
|
|
2976
|
+
// Update search if it changed — unless this navigation is the echo of our
|
|
2977
|
+
// own debounced URL write (its search equals the value we just wrote), or
|
|
2978
|
+
// the user is mid-typing in the box. During typing the URL intentionally
|
|
2979
|
+
// lags the input, so an un-guarded sync here would revert in-flight
|
|
2980
|
+
// keystrokes whenever a write echo lands mid-burst. Genuinely external
|
|
2981
|
+
// navigations (deep links, back/forward) carry a different search value
|
|
2982
|
+
// and still sync.
|
|
2983
|
+
const isOwnWriteEcho =
|
|
2984
|
+
newFilters.search === debouncedSearchTerm ||
|
|
2985
|
+
(typeof document !== 'undefined' && document.activeElement === searchInputRef.current)
|
|
2986
|
+
if (!isOwnWriteEcho && newFilters.search !== searchTerm) {
|
|
2968
2987
|
setSearchTerm(newFilters.search)
|
|
2969
2988
|
}
|
|
2970
2989
|
|
|
@@ -3173,8 +3192,8 @@ export function ResourcesView({
|
|
|
3173
3192
|
shouldPushHistory.current = false
|
|
3174
3193
|
prevSelectedResourceRef.current = current
|
|
3175
3194
|
|
|
3176
|
-
updateURL(selectedKind,
|
|
3177
|
-
}, [selectedKind,
|
|
3195
|
+
updateURL(selectedKind, debouncedSearchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
|
|
3196
|
+
}, [selectedKind, debouncedSearchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
|
|
3178
3197
|
|
|
3179
3198
|
// Handle resource click from URL on mount
|
|
3180
3199
|
useEffect(() => {
|
|
@@ -3605,13 +3624,13 @@ export function ResourcesView({
|
|
|
3605
3624
|
// rows shown) rather than zero results, so the table doesn't flash empty
|
|
3606
3625
|
// while the user is mid-typing a pattern.
|
|
3607
3626
|
const searchRegex = useMemo<{ re: RegExp | null; error: string | null }>(() => {
|
|
3608
|
-
if (!regexMode || !
|
|
3627
|
+
if (!regexMode || !deferredSearchTerm) return { re: null, error: null }
|
|
3609
3628
|
try {
|
|
3610
|
-
return { re: new RegExp(
|
|
3629
|
+
return { re: new RegExp(deferredSearchTerm, 'i'), error: null }
|
|
3611
3630
|
} catch (e) {
|
|
3612
3631
|
return { re: null, error: e instanceof Error ? e.message : 'Invalid regex' }
|
|
3613
3632
|
}
|
|
3614
|
-
}, [regexMode,
|
|
3633
|
+
}, [regexMode, deferredSearchTerm])
|
|
3615
3634
|
|
|
3616
3635
|
// Filter resources by search term, status, problems, and sort
|
|
3617
3636
|
const filteredResources = useMemo(() => {
|
|
@@ -3620,7 +3639,7 @@ export function ResourcesView({
|
|
|
3620
3639
|
let result = resources
|
|
3621
3640
|
|
|
3622
3641
|
// Apply search filter
|
|
3623
|
-
if (
|
|
3642
|
+
if (deferredSearchTerm) {
|
|
3624
3643
|
if (regexMode) {
|
|
3625
3644
|
const re = searchRegex.re
|
|
3626
3645
|
if (re) {
|
|
@@ -3630,7 +3649,7 @@ export function ResourcesView({
|
|
|
3630
3649
|
)
|
|
3631
3650
|
}
|
|
3632
3651
|
} else {
|
|
3633
|
-
const term =
|
|
3652
|
+
const term = deferredSearchTerm.toLowerCase()
|
|
3634
3653
|
result = result.filter((r: any) =>
|
|
3635
3654
|
r.metadata?.name?.toLowerCase().includes(term) ||
|
|
3636
3655
|
r.metadata?.namespace?.toLowerCase().includes(term)
|
|
@@ -3792,7 +3811,7 @@ export function ResourcesView({
|
|
|
3792
3811
|
}
|
|
3793
3812
|
|
|
3794
3813
|
return result
|
|
3795
|
-
}, [resources,
|
|
3814
|
+
}, [resources, deferredSearchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
|
|
3796
3815
|
|
|
3797
3816
|
// For nodes table: compute the majority minor version so outliers can be highlighted
|
|
3798
3817
|
const majorityNodeMinorVersion = useMemo(() => {
|
|
@@ -3919,6 +3938,16 @@ export function ResourcesView({
|
|
|
3919
3938
|
|
|
3920
3939
|
const isCheckboxMode = canBulkSelect && bulkMode
|
|
3921
3940
|
|
|
3941
|
+
// Stable row handlers so the memoized ResourceRowCells skips re-rendering
|
|
3942
|
+
// rows whose data didn't change on a refetch (React Query's structural
|
|
3943
|
+
// sharing keeps unchanged resources referentially identical).
|
|
3944
|
+
const handleRowClick = useCallback((resource: any, isSelected: boolean) => {
|
|
3945
|
+
if (compareMode) toggleComparePick(resource)
|
|
3946
|
+
else if (isCheckboxMode) toggleChecked(resource)
|
|
3947
|
+
else selectResource(resource, isSelected)
|
|
3948
|
+
}, [compareMode, isCheckboxMode, toggleComparePick, toggleChecked, selectResource])
|
|
3949
|
+
const handleRowMouseEnter = useCallback(() => setHighlightedIndex(-1), [])
|
|
3950
|
+
|
|
3922
3951
|
// Filter columns by visibility
|
|
3923
3952
|
const columns = useMemo(() => {
|
|
3924
3953
|
if (visibleColumns.size === 0) return allColumns.filter(c => c.defaultVisible !== false)
|
|
@@ -5045,12 +5074,12 @@ export function ResourcesView({
|
|
|
5045
5074
|
isChecked={checkedResources.has(resourceKey)}
|
|
5046
5075
|
showCheckbox={isCheckboxMode}
|
|
5047
5076
|
majorityNodeMinorVersion={majorityNodeMinorVersion}
|
|
5048
|
-
|
|
5049
|
-
|
|
5077
|
+
onRowClick={handleRowClick}
|
|
5078
|
+
onRowMouseEnter={handleRowMouseEnter}
|
|
5050
5079
|
compareMode={compareMode}
|
|
5051
5080
|
comparePickIndex={pickIdx}
|
|
5052
5081
|
rowHref={rowHrefFor?.(resource)}
|
|
5053
|
-
|
|
5082
|
+
onRowCheckToggle={toggleChecked}
|
|
5054
5083
|
/>
|
|
5055
5084
|
)
|
|
5056
5085
|
}}
|
|
@@ -5200,8 +5229,11 @@ interface ResourceRowCellsProps {
|
|
|
5200
5229
|
isChecked?: boolean
|
|
5201
5230
|
showCheckbox?: boolean
|
|
5202
5231
|
majorityNodeMinorVersion?: string
|
|
5203
|
-
|
|
5204
|
-
|
|
5232
|
+
// Row callbacks receive the resource so the parent can pass referentially
|
|
5233
|
+
// stable handlers — per-row closures would defeat React.memo and re-render
|
|
5234
|
+
// every visible row on each SSE-driven refetch.
|
|
5235
|
+
onRowClick?: (resource: any, isSelected: boolean) => void
|
|
5236
|
+
onRowMouseEnter?: () => void
|
|
5205
5237
|
compareMode?: boolean
|
|
5206
5238
|
/** -1 when not picked; 0 = pick A; 1 = pick B. */
|
|
5207
5239
|
comparePickIndex?: number
|
|
@@ -5209,7 +5241,7 @@ interface ResourceRowCellsProps {
|
|
|
5209
5241
|
* data cells drop their click handlers. The compare-mode chip column
|
|
5210
5242
|
* is unaffected (still toggles picks). */
|
|
5211
5243
|
rowHref?: string
|
|
5212
|
-
|
|
5244
|
+
onRowCheckToggle?: (resource: any) => void
|
|
5213
5245
|
}
|
|
5214
5246
|
|
|
5215
5247
|
function rowHighlightClass(
|
|
@@ -5231,9 +5263,11 @@ function rowHighlightClass(
|
|
|
5231
5263
|
return 'group-hover/row:bg-theme-surface/50'
|
|
5232
5264
|
}
|
|
5233
5265
|
|
|
5234
|
-
function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion,
|
|
5266
|
+
const ResourceRowCells = React.memo(function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, hasSpacerColumn, isSelected, isHighlighted, isChecked, showCheckbox, majorityNodeMinorVersion, onRowClick, onRowMouseEnter, compareMode, comparePickIndex = -1, rowHref, onRowCheckToggle }: ResourceRowCellsProps) {
|
|
5235
5267
|
const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted, isChecked)
|
|
5236
5268
|
const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
|
|
5269
|
+
const onClick = onRowClick ? () => onRowClick(resource, !!isSelected) : undefined
|
|
5270
|
+
const onMouseEnter = onRowMouseEnter
|
|
5237
5271
|
// When the host supplies an anchor, drop per-cell onClick for the data
|
|
5238
5272
|
// columns: the anchor is the only navigation surface. The compare-mode
|
|
5239
5273
|
// chip column keeps its onClick so pick toggling still works.
|
|
@@ -5268,7 +5302,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
5268
5302
|
{showCheckbox && (
|
|
5269
5303
|
<td
|
|
5270
5304
|
className={clsx('border-b-subtle text-center px-0 py-3 w-10 cursor-pointer transition-colors', rowHighlight)}
|
|
5271
|
-
onClick={(e) => { e.stopPropagation();
|
|
5305
|
+
onClick={(e) => { e.stopPropagation(); onRowCheckToggle?.(resource) }}
|
|
5272
5306
|
>
|
|
5273
5307
|
<input
|
|
5274
5308
|
type="checkbox"
|
|
@@ -5304,7 +5338,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
|
|
|
5304
5338
|
{hasSpacerColumn && <td className="border-b-subtle p-0" />}
|
|
5305
5339
|
</>
|
|
5306
5340
|
)
|
|
5307
|
-
}
|
|
5341
|
+
})
|
|
5308
5342
|
|
|
5309
5343
|
const VirtuosoTableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
|
|
5310
5344
|
function VirtuosoTableRow(props, ref) {
|
|
@@ -6032,27 +6066,91 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
|
|
|
6032
6066
|
const ip = resource.status?.podIP || '-'
|
|
6033
6067
|
return <span className="text-sm text-theme-text-secondary font-mono">{ip}</span>
|
|
6034
6068
|
}
|
|
6035
|
-
case 'cpu':
|
|
6036
|
-
const key = `${resource.metadata?.namespace}/${resource.metadata?.name}`
|
|
6037
|
-
const m = metrics.pods.get(key)
|
|
6038
|
-
if (!m || m.cpu === 0) return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
6039
|
-
const denom = m.cpuLimit || m.cpuRequest
|
|
6040
|
-
if (!denom) return <span className="text-sm text-theme-text-secondary font-mono">{formatCPU(m.cpu)}</span>
|
|
6041
|
-
const pct = (m.cpu / denom) * 100
|
|
6042
|
-
const marker = m.cpuLimit > 0 && m.cpuRequest > 0 ? (m.cpuRequest / m.cpuLimit) * 100 : undefined
|
|
6043
|
-
const tip = buildResourceTooltip('CPU', m.cpu, m.cpuRequest, m.cpuLimit, formatCPU)
|
|
6044
|
-
return <ResourceBar used={formatCPU(m.cpu)} total={formatCPU(denom)} percent={pct} colorScheme={getBulletBarScheme(pct, marker)} markerPercent={marker} tooltip={tip} />
|
|
6045
|
-
}
|
|
6069
|
+
case 'cpu':
|
|
6046
6070
|
case 'memory': {
|
|
6071
|
+
const kind = column as 'cpu' | 'memory'
|
|
6072
|
+
const isCPU = kind === 'cpu'
|
|
6047
6073
|
const key = `${resource.metadata?.namespace}/${resource.metadata?.name}`
|
|
6048
6074
|
const m = metrics.pods.get(key)
|
|
6049
|
-
if (!m
|
|
6050
|
-
const
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
6054
|
-
|
|
6055
|
-
|
|
6075
|
+
if (!m) return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
6076
|
+
const label = isCPU ? 'CPU' : 'Memory'
|
|
6077
|
+
const format = isCPU ? formatCPU : formatMemoryShort
|
|
6078
|
+
|
|
6079
|
+
// Multi-container pods carry a per-container breakdown; single-container
|
|
6080
|
+
// pods fall back to the (union-summed) pod-level fields as one synthetic
|
|
6081
|
+
// container so the rest of the logic is uniform.
|
|
6082
|
+
const list: ContainerResourceMetrics[] = (m.containers && m.containers.length > 0)
|
|
6083
|
+
? m.containers
|
|
6084
|
+
: [{
|
|
6085
|
+
name: resource.spec?.containers?.[0]?.name ?? resource.metadata?.name ?? '',
|
|
6086
|
+
cpu: m.cpu, cpuRequest: m.cpuRequest, cpuLimit: m.cpuLimit,
|
|
6087
|
+
memory: m.memory, memoryRequest: m.memoryRequest, memoryLimit: m.memoryLimit,
|
|
6088
|
+
}]
|
|
6089
|
+
|
|
6090
|
+
const { mode, totalUsage, denom, markerPct, unlimitedCount } = podAggregate(list, kind)
|
|
6091
|
+
if (totalUsage === 0) return <span className="text-sm text-theme-text-tertiary">-</span>
|
|
6092
|
+
|
|
6093
|
+
// Pod-vs-node context: how full the pod's node is (the risk signal) and
|
|
6094
|
+
// how much of it this pod takes. Only when node metrics are loaded.
|
|
6095
|
+
const nodeName = resource.spec?.nodeName as string | undefined
|
|
6096
|
+
const node = nodeName ? metrics.nodes.get(nodeName) : undefined
|
|
6097
|
+
const nodeAlloc = node ? (isCPU ? node.cpuAllocatable : node.memoryAllocatable) : 0
|
|
6098
|
+
const nodeUsage = node ? (isCPU ? node.cpu : node.memory) : 0
|
|
6099
|
+
// Zero usage means metrics-server has no sample for the node (down, or not
|
|
6100
|
+
// yet scraped) — the same "no data" NodeCell renders blank — so skip the
|
|
6101
|
+
// line rather than assert a misleading "0% used" that reads as safe.
|
|
6102
|
+
const nodeCtx = node && nodeAlloc > 0 && nodeUsage > 0
|
|
6103
|
+
? {
|
|
6104
|
+
name: nodeName!,
|
|
6105
|
+
usedPct: (nodeUsage / nodeAlloc) * 100,
|
|
6106
|
+
podSharePct: (totalUsage / nodeAlloc) * 100,
|
|
6107
|
+
}
|
|
6108
|
+
: undefined
|
|
6109
|
+
|
|
6110
|
+
const tip = buildContainerResourceTooltip(label, list, kind, format, nodeCtx)
|
|
6111
|
+
|
|
6112
|
+
// Every container is limited → aggregate bar against the summed limit; a
|
|
6113
|
+
// real pod-level ceiling, so it may go red.
|
|
6114
|
+
if (mode === 'limit') {
|
|
6115
|
+
const pct = denom > 0 ? (totalUsage / denom) * 100 : 0
|
|
6116
|
+
return (
|
|
6117
|
+
<ResourceBar
|
|
6118
|
+
used={format(totalUsage)}
|
|
6119
|
+
total={format(denom)}
|
|
6120
|
+
percent={pct}
|
|
6121
|
+
colorScheme={getBulletBarScheme(pct, markerPct)}
|
|
6122
|
+
markerPercent={markerPct}
|
|
6123
|
+
tooltip={tip}
|
|
6124
|
+
/>
|
|
6125
|
+
)
|
|
6126
|
+
}
|
|
6127
|
+
|
|
6128
|
+
// No limits but some requests → neutral bar against the summed request
|
|
6129
|
+
// (no ceiling → never red), no marker.
|
|
6130
|
+
if (mode === 'request') {
|
|
6131
|
+
const pct = denom > 0 ? (totalUsage / denom) * 100 : 0
|
|
6132
|
+
return (
|
|
6133
|
+
<ResourceBar
|
|
6134
|
+
used={format(totalUsage)}
|
|
6135
|
+
total={format(denom)}
|
|
6136
|
+
percent={pct}
|
|
6137
|
+
colorScheme="quiet"
|
|
6138
|
+
tooltip={tip}
|
|
6139
|
+
/>
|
|
6140
|
+
)
|
|
6141
|
+
}
|
|
6142
|
+
|
|
6143
|
+
// partial (some limited, some not — the sum is not a real ceiling) or none
|
|
6144
|
+
// (nothing set) → plain usage number plus a faint tag.
|
|
6145
|
+
const tag = mode === 'partial' ? `${unlimitedCount} unbounded` : 'no limit'
|
|
6146
|
+
return (
|
|
6147
|
+
<Tooltip content={tip} delay={200} position="top" wrapperClassName="w-full min-w-0">
|
|
6148
|
+
<span className="inline-flex items-center gap-1.5 min-w-0">
|
|
6149
|
+
<span className="text-sm text-theme-text-secondary font-mono">{format(totalUsage)}</span>
|
|
6150
|
+
<span className="text-[10px] text-theme-text-tertiary shrink-0">{tag}</span>
|
|
6151
|
+
</span>
|
|
6152
|
+
</Tooltip>
|
|
6153
|
+
)
|
|
6056
6154
|
}
|
|
6057
6155
|
case 'gpu': {
|
|
6058
6156
|
const count = getPodGpuCount(resource)
|
|
@@ -6575,65 +6673,176 @@ function getBulletBarScheme(_usagePct: number, _markerPct: number | undefined):
|
|
|
6575
6673
|
return 'utilization'
|
|
6576
6674
|
}
|
|
6577
6675
|
|
|
6578
|
-
|
|
6579
|
-
|
|
6580
|
-
|
|
6581
|
-
|
|
6582
|
-
|
|
6583
|
-
|
|
6584
|
-
)
|
|
6585
|
-
|
|
6586
|
-
|
|
6587
|
-
|
|
6588
|
-
|
|
6589
|
-
|
|
6590
|
-
|
|
6591
|
-
|
|
6592
|
-
|
|
6593
|
-
|
|
6594
|
-
|
|
6595
|
-
|
|
6596
|
-
|
|
6597
|
-
|
|
6598
|
-
|
|
6599
|
-
|
|
6600
|
-
|
|
6601
|
-
|
|
6602
|
-
|
|
6603
|
-
|
|
6604
|
-
|
|
6676
|
+
// Max per-container rows shown in the compact cell tooltip before collapsing
|
|
6677
|
+
// the tail into a "+N more" line.
|
|
6678
|
+
const CONTAINER_TOOLTIP_CAP = 3
|
|
6679
|
+
|
|
6680
|
+
// Per-container resource reading. The yardstick is the container's own limit
|
|
6681
|
+
// when set, otherwise its request. A limit means an enforced ceiling (usage can
|
|
6682
|
+
// be throttled / OOM-killed → the bar may go red); a request-only container has
|
|
6683
|
+
// no ceiling, so usage above request is normal and the bar stays neutral.
|
|
6684
|
+
type Yardstick = 'limit' | 'request' | 'none'
|
|
6685
|
+
|
|
6686
|
+
export function readContainer(c: ContainerResourceMetrics, kind: 'cpu' | 'memory') {
|
|
6687
|
+
const isCPU = kind === 'cpu'
|
|
6688
|
+
const usage = isCPU ? c.cpu : c.memory
|
|
6689
|
+
const limit = isCPU ? c.cpuLimit : c.memoryLimit
|
|
6690
|
+
const request = isCPU ? c.cpuRequest : c.memoryRequest
|
|
6691
|
+
let yardstick: Yardstick = 'none'
|
|
6692
|
+
let denom = 0
|
|
6693
|
+
if (limit > 0) {
|
|
6694
|
+
yardstick = 'limit'
|
|
6695
|
+
denom = limit
|
|
6696
|
+
} else if (request > 0) {
|
|
6697
|
+
yardstick = 'request'
|
|
6698
|
+
denom = request
|
|
6699
|
+
}
|
|
6700
|
+
const pct = denom > 0 ? (usage / denom) * 100 : -1
|
|
6701
|
+
return { usage, limit, request, yardstick, denom, pct }
|
|
6702
|
+
}
|
|
6703
|
+
|
|
6704
|
+
interface PodAggregate {
|
|
6705
|
+
// limit: every container is limited → a real ceiling exists (bar vs summed
|
|
6706
|
+
// limit). partial: some containers limited, some not → the sum is not a true
|
|
6707
|
+
// ceiling, so plain usage + an "unbounded" tag. request: no limits but some
|
|
6708
|
+
// requests → neutral bar vs summed request. none: nothing set → plain usage.
|
|
6709
|
+
mode: 'limit' | 'partial' | 'request' | 'none'
|
|
6710
|
+
totalUsage: number
|
|
6711
|
+
/** summed limit (limit mode) or summed request (request mode); 0 otherwise. */
|
|
6712
|
+
denom: number
|
|
6713
|
+
/** request marker as % of summed limit; limit mode only. */
|
|
6714
|
+
markerPct?: number
|
|
6715
|
+
/** number of containers with no limit set. */
|
|
6716
|
+
unlimitedCount: number
|
|
6717
|
+
}
|
|
6718
|
+
|
|
6719
|
+
// podAggregate reduces a pod's containers to the aggregate the compact cell
|
|
6720
|
+
// headline renders — total usage measured against a pod-level yardstick. It
|
|
6721
|
+
// keeps the dominant consumer visible instead of demoting it behind a small
|
|
6722
|
+
// limited container, and stays honest about partial limits (which don't form a
|
|
6723
|
+
// real ceiling).
|
|
6724
|
+
export function podAggregate(list: ContainerResourceMetrics[], kind: 'cpu' | 'memory'): PodAggregate {
|
|
6725
|
+
const isCPU = kind === 'cpu'
|
|
6726
|
+
let totalUsage = 0
|
|
6727
|
+
let limitedCount = 0
|
|
6728
|
+
let requestedCount = 0
|
|
6729
|
+
let unlimitedCount = 0
|
|
6730
|
+
let summedLimit = 0
|
|
6731
|
+
let summedRequest = 0
|
|
6732
|
+
for (const c of list) {
|
|
6733
|
+
const usage = isCPU ? c.cpu : c.memory
|
|
6734
|
+
const limit = isCPU ? c.cpuLimit : c.memoryLimit
|
|
6735
|
+
const request = isCPU ? c.cpuRequest : c.memoryRequest
|
|
6736
|
+
totalUsage += usage
|
|
6737
|
+
if (limit > 0) {
|
|
6738
|
+
limitedCount++
|
|
6739
|
+
summedLimit += limit
|
|
6605
6740
|
} else {
|
|
6606
|
-
|
|
6741
|
+
unlimitedCount++
|
|
6742
|
+
}
|
|
6743
|
+
if (request > 0) {
|
|
6744
|
+
requestedCount++
|
|
6745
|
+
summedRequest += request
|
|
6607
6746
|
}
|
|
6608
|
-
} else if (request > 0) {
|
|
6609
|
-
guidance = usage > request
|
|
6610
|
-
? `Exceeds request with no limit — unbounded ${isCPU ? 'CPU' : 'memory'} access`
|
|
6611
|
-
: 'No limit set — pod can burst beyond request'
|
|
6612
|
-
} else {
|
|
6613
|
-
guidance = 'No request or limit configured'
|
|
6614
6747
|
}
|
|
6748
|
+
const allLimited = list.length > 0 && limitedCount === list.length
|
|
6749
|
+
if (allLimited) {
|
|
6750
|
+
return {
|
|
6751
|
+
mode: 'limit',
|
|
6752
|
+
totalUsage,
|
|
6753
|
+
denom: summedLimit,
|
|
6754
|
+
markerPct: summedRequest > 0 ? (summedRequest / summedLimit) * 100 : undefined,
|
|
6755
|
+
unlimitedCount,
|
|
6756
|
+
}
|
|
6757
|
+
}
|
|
6758
|
+
if (limitedCount > 0) {
|
|
6759
|
+
return { mode: 'partial', totalUsage, denom: 0, unlimitedCount }
|
|
6760
|
+
}
|
|
6761
|
+
if (requestedCount > 0) {
|
|
6762
|
+
return { mode: 'request', totalUsage, denom: summedRequest, unlimitedCount }
|
|
6763
|
+
}
|
|
6764
|
+
return { mode: 'none', totalUsage, denom: 0, unlimitedCount }
|
|
6765
|
+
}
|
|
6766
|
+
|
|
6767
|
+
// buildContainerResourceTooltip renders one row per container — usage against
|
|
6768
|
+
// its OWN yardstick (limit if set, otherwise request) and percentage — sorted
|
|
6769
|
+
// by pct descending, intermixing limited and request-only. Containers with
|
|
6770
|
+
// neither render the words "no limit". Capped at CONTAINER_TOOLTIP_CAP rows,
|
|
6771
|
+
// with a final "+N more" line pointing at the pod.
|
|
6772
|
+
// NodeContext answers "is this pod at risk from its node?" — how full the node
|
|
6773
|
+
// is (the risk signal) and how much of the node this pod takes (attribution).
|
|
6774
|
+
interface NodeContext {
|
|
6775
|
+
name: string
|
|
6776
|
+
usedPct: number
|
|
6777
|
+
podSharePct: number
|
|
6778
|
+
}
|
|
6779
|
+
|
|
6780
|
+
// A node this full puts every pod on it at risk (eviction / OOM / throttling),
|
|
6781
|
+
// regardless of the pod's own limit headroom — so the line goes amber here.
|
|
6782
|
+
const NODE_PRESSURE_PCT = 85
|
|
6783
|
+
|
|
6784
|
+
function formatSharePct(pct: number): string {
|
|
6785
|
+
if (pct > 0 && pct < 1) return '<1%'
|
|
6786
|
+
return `${Math.round(pct)}%`
|
|
6787
|
+
}
|
|
6788
|
+
|
|
6789
|
+
function buildContainerResourceTooltip(
|
|
6790
|
+
label: 'CPU' | 'Memory',
|
|
6791
|
+
containers: ContainerResourceMetrics[],
|
|
6792
|
+
kind: 'cpu' | 'memory',
|
|
6793
|
+
formatFn: (n: number) => string,
|
|
6794
|
+
nodeCtx?: NodeContext,
|
|
6795
|
+
) {
|
|
6796
|
+
const rows = [...containers].sort((a, b) => {
|
|
6797
|
+
const diff = readContainer(b, kind).pct - readContainer(a, kind).pct
|
|
6798
|
+
return diff !== 0 ? diff : a.name.localeCompare(b.name)
|
|
6799
|
+
})
|
|
6800
|
+
const shown = rows.slice(0, CONTAINER_TOOLTIP_CAP)
|
|
6801
|
+
const remaining = rows.length - shown.length
|
|
6615
6802
|
|
|
6616
6803
|
return (
|
|
6617
|
-
<div className="whitespace-normal w-
|
|
6618
|
-
<div className="
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
6634
|
-
|
|
6635
|
-
|
|
6804
|
+
<div className="whitespace-normal w-72 flex flex-col gap-2 py-0.5">
|
|
6805
|
+
<div className="text-[11px] text-theme-text-tertiary uppercase tracking-wide">{label} by container</div>
|
|
6806
|
+
<div className="flex flex-col gap-2">
|
|
6807
|
+
{shown.map((c) => {
|
|
6808
|
+
const r = readContainer(c, kind)
|
|
6809
|
+
// Container name on its own line, the numbers below it. Limited
|
|
6810
|
+
// containers also show the request (the tooltip is the detail
|
|
6811
|
+
// surface and shouldn't drop it); request-only/unset show their
|
|
6812
|
+
// single yardstick or "no limit".
|
|
6813
|
+
const detail = r.yardstick === 'limit'
|
|
6814
|
+
? `${formatFn(r.usage)} · ${Math.round(r.pct)}% · ${formatFn(r.denom)} limit${r.request > 0 ? ` · req ${formatFn(r.request)}` : ''}`
|
|
6815
|
+
: r.yardstick === 'request'
|
|
6816
|
+
? `${formatFn(r.usage)} · ${Math.round(r.pct)}% · ${formatFn(r.denom)} request`
|
|
6817
|
+
: `${formatFn(r.usage)} · no limit`
|
|
6818
|
+
return (
|
|
6819
|
+
<div key={c.name} className="flex flex-col leading-snug">
|
|
6820
|
+
<span className="text-[11px] text-theme-text-tertiary truncate">{c.name}</span>
|
|
6821
|
+
<span className="text-xs font-mono text-theme-text-primary">{detail}</span>
|
|
6822
|
+
</div>
|
|
6823
|
+
)
|
|
6824
|
+
})}
|
|
6636
6825
|
</div>
|
|
6826
|
+
{remaining > 0 && (
|
|
6827
|
+
<div className="text-[11px] text-theme-text-tertiary border-t border-theme-border/50 pt-1">
|
|
6828
|
+
+{remaining} more → open pod
|
|
6829
|
+
</div>
|
|
6830
|
+
)}
|
|
6831
|
+
{nodeCtx && (
|
|
6832
|
+
<div className="flex flex-col leading-snug border-t border-theme-border/50 pt-1">
|
|
6833
|
+
<span className="text-[11px] text-theme-text-tertiary truncate">Node {nodeCtx.name}</span>
|
|
6834
|
+
<span
|
|
6835
|
+
className={clsx(
|
|
6836
|
+
'text-[11px]',
|
|
6837
|
+
nodeCtx.usedPct >= NODE_PRESSURE_PCT
|
|
6838
|
+
? 'text-amber-500 font-medium'
|
|
6839
|
+
: 'text-theme-text-secondary',
|
|
6840
|
+
)}
|
|
6841
|
+
>
|
|
6842
|
+
{formatSharePct(nodeCtx.usedPct)} used · this pod {formatSharePct(nodeCtx.podSharePct)}
|
|
6843
|
+
</span>
|
|
6844
|
+
</div>
|
|
6845
|
+
)}
|
|
6637
6846
|
</div>
|
|
6638
6847
|
)
|
|
6639
6848
|
}
|
|
@@ -740,8 +740,11 @@ export function PodRenderer({
|
|
|
740
740
|
{(metricsHistory?.containers || currentMetrics?.containers || []).map((historyContainer) => {
|
|
741
741
|
// Find current metrics for this container
|
|
742
742
|
const currentContainerMetrics = currentMetrics?.containers?.find(c => c.name === historyContainer.name)
|
|
743
|
-
// Find the container spec to compare against limits
|
|
743
|
+
// Find the container spec to compare against limits. Native
|
|
744
|
+
// sidecars live in initContainers (restartPolicy Always), so
|
|
745
|
+
// fall through to them or their chart shows no limit line.
|
|
744
746
|
const containerSpec = containers.find((c: any) => c.name === historyContainer.name)
|
|
747
|
+
|| initContainers.find((c: any) => c.name === historyContainer.name && c.restartPolicy === 'Always')
|
|
745
748
|
const limits = containerSpec?.resources?.limits
|
|
746
749
|
const requests = containerSpec?.resources?.requests
|
|
747
750
|
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { useState, useMemo, useEffect, useCallback, useRef } from 'react'
|
|
2
|
+
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
|
2
3
|
import { PaneLoader } from '../ui/PaneLoader'
|
|
3
4
|
import { Tooltip } from '../ui/Tooltip'
|
|
4
5
|
import {
|
|
@@ -117,6 +118,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
117
118
|
const [searchInternal, setSearchInternal] = useState('')
|
|
118
119
|
const searchTerm = searchProp ?? searchInternal
|
|
119
120
|
const setSearchTerm = onSearchChange ?? setSearchInternal
|
|
121
|
+
// Coalesce typing bursts: re-filtering the aggregated list per keystroke is
|
|
122
|
+
// what makes the search box feel dead on large timelines. Clearing flushes
|
|
123
|
+
// immediately.
|
|
124
|
+
const debouncedSearchTerm = useDebouncedValue(searchTerm, 300, (v) => v === '')
|
|
120
125
|
const [activityFilterInternal, setActivityFilterInternal] = useState<ActivityFilterKey[]>(
|
|
121
126
|
initialFilter && initialFilter !== 'all' ? [initialFilter] : [],
|
|
122
127
|
)
|
|
@@ -174,10 +179,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
|
|
|
174
179
|
if (!matchesActivityFilter(item, activityTypeFilter)) return false
|
|
175
180
|
if (kindFilter.length > 0 && !kindFilter.includes(item.kind)) return false
|
|
176
181
|
if (!showDeleted && item.eventType === 'delete') return false
|
|
177
|
-
if (!matchesTimelineSearch(item,
|
|
182
|
+
if (!matchesTimelineSearch(item, debouncedSearchTerm)) return false
|
|
178
183
|
return true
|
|
179
184
|
})
|
|
180
|
-
}, [events, activityTypeFilter, kindFilter,
|
|
185
|
+
}, [events, activityTypeFilter, kindFilter, debouncedSearchTerm, showDeleted])
|
|
181
186
|
|
|
182
187
|
// Aggregated event group type
|
|
183
188
|
type AggregatedItem = {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Fragment, useState, useMemo, useRef, useCallback, useEffect } from 'react'
|
|
2
2
|
import { clsx } from 'clsx'
|
|
3
|
+
import { useDebouncedValue } from '../../hooks/useDebouncedValue'
|
|
3
4
|
import {
|
|
4
5
|
AlertCircle,
|
|
5
6
|
AlertTriangle,
|
|
@@ -795,6 +796,10 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
795
796
|
const [searchInternal, setSearchInternal] = useState('')
|
|
796
797
|
const searchTerm = searchProp ?? searchInternal
|
|
797
798
|
const setSearchTerm = onSearchChange ?? setSearchInternal
|
|
799
|
+
// Filtering and re-ranking rebuild the whole lane board (tens of thousands
|
|
800
|
+
// of DOM mutations on a large timeline) — coalesce a typing burst into one
|
|
801
|
+
// rebuild instead of one per keystroke. Clearing flushes immediately.
|
|
802
|
+
const debouncedSearchTerm = useDebouncedValue(searchTerm, 300, (v) => v === '')
|
|
798
803
|
const [activityFilterInternal, setActivityFilterInternal] = useState<ActivityFilterKey[]>([])
|
|
799
804
|
const activityFilter = activityFilterProp ?? activityFilterInternal
|
|
800
805
|
const setActivityFilter = onActivityFilterChange ?? setActivityFilterInternal
|
|
@@ -939,10 +944,10 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
939
944
|
if (!showDeleted && e.eventType === 'delete') return false
|
|
940
945
|
if (!matchesActivityFilter(e, activityFilter)) return false
|
|
941
946
|
if (kindFilter.length > 0 && !kindFilter.includes(e.kind)) return false
|
|
942
|
-
if (!matchesTimelineSearch(e,
|
|
947
|
+
if (!matchesTimelineSearch(e, debouncedSearchTerm)) return false
|
|
943
948
|
return true
|
|
944
949
|
})
|
|
945
|
-
}, [events,
|
|
950
|
+
}, [events, debouncedSearchTerm, showDeleted, activityFilter, kindFilter])
|
|
946
951
|
|
|
947
952
|
// Kind dropdown options: seed set + every kind present in the (unfiltered)
|
|
948
953
|
// events. The swimlane fetches all kinds, so deriving from events is stable.
|
|
@@ -1111,8 +1116,8 @@ export function TimelineSwimlanes({ events, isLoading, onResourceClick, viewMode
|
|
|
1111
1116
|
// calm across it is the whole point. Inert when !isLive (local / WorkloadView).
|
|
1112
1117
|
const laneOrderRef = useRef<string[] | null>(null)
|
|
1113
1118
|
const rankResetKey = useMemo(
|
|
1114
|
-
() => JSON.stringify([sort, grouping,
|
|
1115
|
-
[sort, grouping,
|
|
1119
|
+
() => JSON.stringify([sort, grouping, debouncedSearchTerm, showDeleted, activityFilter, kindFilter]),
|
|
1120
|
+
[sort, grouping, debouncedSearchTerm, showDeleted, activityFilter, kindFilter],
|
|
1116
1121
|
)
|
|
1117
1122
|
const rankResetKeyRef = useRef(rankResetKey)
|
|
1118
1123
|
const wasLiveRef = useRef(false)
|
package/src/hooks/index.ts
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { useEffect, useState } from 'react'
|
|
2
|
+
|
|
3
|
+
/** Trailing-edge debounce of a value. Unlike useDeferredValue (which yields to
|
|
4
|
+
* React's scheduler but still emits once per input change), this coalesces a
|
|
5
|
+
* burst of changes into one emission after `delayMs` of quiet — the right tool
|
|
6
|
+
* when each downstream emission has a fixed cost regardless of render pressure
|
|
7
|
+
* (router URL writes, live-order re-ranks).
|
|
8
|
+
*
|
|
9
|
+
* `flushWhen` values bypass the delay (e.g. an emptied search box: a delayed
|
|
10
|
+
* clear makes the × button feel broken). The flush must reset the pending
|
|
11
|
+
* timer state INSIDE the hook — masking it at the call site
|
|
12
|
+
* (`v === '' ? '' : debounced`) leaves the stale value armed, and it
|
|
13
|
+
* resurfaces if the user types again before the timer fires. */
|
|
14
|
+
export function useDebouncedValue<T>(value: T, delayMs: number, flushWhen?: (value: T) => boolean): T {
|
|
15
|
+
const [debounced, setDebounced] = useState(value)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (Object.is(debounced, value)) return
|
|
19
|
+
if (flushWhen?.(value)) {
|
|
20
|
+
setDebounced(value)
|
|
21
|
+
return
|
|
22
|
+
}
|
|
23
|
+
const timer = setTimeout(() => setDebounced(value), delayMs)
|
|
24
|
+
return () => clearTimeout(timer)
|
|
25
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps -- re-arm only when the input changes; `debounced` catching up (or an inline `flushWhen` identity) must not restart the timer
|
|
26
|
+
}, [value, delayMs])
|
|
27
|
+
|
|
28
|
+
return debounced
|
|
29
|
+
}
|
package/src/types/core.ts
CHANGED
|
@@ -1032,15 +1032,28 @@ export type ChartSource = 'local' | 'artifacthub'
|
|
|
1032
1032
|
// ============================================================================
|
|
1033
1033
|
|
|
1034
1034
|
// Top metrics types (bulk, for resource table view)
|
|
1035
|
+
export interface ContainerResourceMetrics {
|
|
1036
|
+
name: string
|
|
1037
|
+
cpu: number // nanocores (usage)
|
|
1038
|
+
cpuRequest: number // nanocores
|
|
1039
|
+
cpuLimit: number // nanocores
|
|
1040
|
+
memory: number // bytes (usage)
|
|
1041
|
+
memoryRequest: number // bytes
|
|
1042
|
+
memoryLimit: number // bytes
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1035
1045
|
export interface TopPodMetrics {
|
|
1036
1046
|
namespace: string
|
|
1037
1047
|
name: string
|
|
1038
1048
|
cpu: number // nanocores (usage)
|
|
1039
1049
|
memory: number // bytes (usage)
|
|
1040
|
-
cpuRequest: number // nanocores (sum across containers)
|
|
1041
|
-
cpuLimit: number // nanocores (sum across containers)
|
|
1042
|
-
memoryRequest: number // bytes (sum across containers)
|
|
1043
|
-
memoryLimit: number // bytes (sum across containers)
|
|
1050
|
+
cpuRequest: number // nanocores (sum across running containers)
|
|
1051
|
+
cpuLimit: number // nanocores (sum across running containers)
|
|
1052
|
+
memoryRequest: number // bytes (sum across running containers)
|
|
1053
|
+
memoryLimit: number // bytes (sum across running containers)
|
|
1054
|
+
// Per-container breakdown; present only for pods with more than one running
|
|
1055
|
+
// container (regular + native sidecars). Absent for single-container pods.
|
|
1056
|
+
containers?: ContainerResourceMetrics[]
|
|
1044
1057
|
}
|
|
1045
1058
|
|
|
1046
1059
|
export interface TopNodeMetrics {
|