@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.9.0",
3
+ "version": "1.9.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -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 (!query) {
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(query, isCaseSensitive ? 'g' : 'gi')
56
+ pattern = new RegExp(deferredQuery, isCaseSensitive ? 'g' : 'gi')
52
57
  } else {
53
- pattern = new RegExp(escapeRegExp(query), isCaseSensitive ? 'g' : 'gi')
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, query, isRegex, isCaseSensitive])
73
+ }, [entries, deferredQuery, isRegex, isCaseSensitive])
69
74
 
70
75
  // Filtered entries for filter mode
71
76
  const filteredEntries = useMemo(() => {
72
- if (!isFilterMode || !query) return entries
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, query, matchIndices])
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
- if (newFilters.search !== searchTerm) {
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, searchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
3177
- }, [selectedKind, searchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource, updateURL, basePath, locationPathname])
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 || !searchTerm) return { re: null, error: null }
3627
+ if (!regexMode || !deferredSearchTerm) return { re: null, error: null }
3609
3628
  try {
3610
- return { re: new RegExp(searchTerm, 'i'), error: null }
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, searchTerm])
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 (searchTerm) {
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 = searchTerm.toLowerCase()
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, searchTerm, regexMode, searchRegex, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, labelSelector, ownerKind, ownerName, selectedKind.name, sortColumn, sortDirection, getSortValue, extraColumnsByKey, podMatchesProblemFilter])
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
- onClick={() => compareMode ? toggleComparePick(resource) : isCheckboxMode ? toggleChecked(resource) : selectResource(resource, isSelected)}
5049
- onMouseEnter={() => setHighlightedIndex(-1)}
5077
+ onRowClick={handleRowClick}
5078
+ onRowMouseEnter={handleRowMouseEnter}
5050
5079
  compareMode={compareMode}
5051
5080
  comparePickIndex={pickIdx}
5052
5081
  rowHref={rowHrefFor?.(resource)}
5053
- onCheckToggle={() => toggleChecked(resource)}
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
- onClick?: () => void
5204
- onMouseEnter?: () => void
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
- onCheckToggle?: () => void
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, onClick, onMouseEnter, compareMode, comparePickIndex = -1, rowHref, onCheckToggle }: ResourceRowCellsProps) {
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(); onCheckToggle?.() }}
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 || m.memory === 0) return <span className="text-sm text-theme-text-tertiary">-</span>
6050
- const denom = m.memoryLimit || m.memoryRequest
6051
- if (!denom) return <span className="text-sm text-theme-text-secondary font-mono">{formatMemoryShort(m.memory)}</span>
6052
- const pct = (m.memory / denom) * 100
6053
- const marker = m.memoryLimit > 0 && m.memoryRequest > 0 ? (m.memoryRequest / m.memoryLimit) * 100 : undefined
6054
- const tip = buildResourceTooltip('Memory', m.memory, m.memoryRequest, m.memoryLimit, formatMemoryShort)
6055
- return <ResourceBar used={formatMemoryShort(m.memory)} total={formatMemoryShort(denom)} percent={pct} colorScheme={getBulletBarScheme(pct, marker)} markerPercent={marker} tooltip={tip} />
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
- function buildResourceTooltip(
6579
- type: 'CPU' | 'Memory',
6580
- usage: number,
6581
- request: number,
6582
- limit: number,
6583
- formatFn: (n: number) => string,
6584
- ) {
6585
- const isCPU = type === 'CPU'
6586
-
6587
- let guidance: string
6588
- if (limit > 0 && request > 0) {
6589
- const pctOfLimit = (usage / limit) * 100
6590
- if (pctOfLimit > 90) {
6591
- guidance = isCPU
6592
- ? 'Near the limit CPU may be throttled'
6593
- : 'Near the limit — at risk of OOM kill'
6594
- } else if (usage > request) {
6595
- guidance = 'Exceeds request — consider raising it if sustained'
6596
- } else {
6597
- guidance = 'Below request — healthy headroom'
6598
- }
6599
- } else if (limit > 0) {
6600
- const pctOfLimit = (usage / limit) * 100
6601
- if (pctOfLimit > 90) {
6602
- guidance = isCPU
6603
- ? 'Near the limit CPU may be throttled'
6604
- : 'Near the limit — at risk of OOM kill'
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
- guidance = 'No request set — scheduling may be suboptimal'
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-52 flex flex-col gap-1.5 py-0.5">
6618
- <div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs font-mono">
6619
- <span className="text-theme-text-tertiary">Usage</span>
6620
- <span className="text-theme-text-primary">{formatFn(usage)}</span>
6621
- {request > 0 && (
6622
- <>
6623
- <span className="text-theme-text-tertiary">Request</span>
6624
- <span className="text-theme-text-primary">{formatFn(request)}</span>
6625
- </>
6626
- )}
6627
- {limit > 0 && (
6628
- <>
6629
- <span className="text-theme-text-tertiary">Limit</span>
6630
- <span className="text-theme-text-primary">{formatFn(limit)}</span>
6631
- </>
6632
- )}
6633
- </div>
6634
- <div className="text-[11px] text-theme-text-secondary border-t border-theme-border/50 pt-1">
6635
- {guidance}
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, searchTerm)) return false
182
+ if (!matchesTimelineSearch(item, debouncedSearchTerm)) return false
178
183
  return true
179
184
  })
180
- }, [events, activityTypeFilter, kindFilter, searchTerm, showDeleted])
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, searchTerm)) return false
947
+ if (!matchesTimelineSearch(e, debouncedSearchTerm)) return false
943
948
  return true
944
949
  })
945
- }, [events, searchTerm, showDeleted, activityFilter, kindFilter])
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, searchTerm, showDeleted, activityFilter, kindFilter]),
1115
- [sort, grouping, searchTerm, showDeleted, activityFilter, kindFilter],
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)
@@ -1,4 +1,5 @@
1
1
  export { useAnimatedUnmount } from './useAnimatedUnmount'
2
+ export { useDebouncedValue } from './useDebouncedValue'
2
3
  export { useRefreshAnimation } from './useRefreshAnimation'
3
4
  export {
4
5
  KeyboardShortcutProvider,
@@ -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 {