@skyhook-io/k8s-ui 1.8.15 → 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.
@@ -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,
@@ -133,13 +134,14 @@ import {
133
134
  podMatchesProblemCategory,
134
135
  SEVERITY_DOT_COLOR,
135
136
  } from './resource-utils'
136
- import { SEVERITY_BADGE, EVENT_TYPE_COLORS, SEVERITY_TEXT } from '../../utils/badge-colors'
137
+ import { SEVERITY_BADGE, EVENT_TYPE_COLORS } from '../../utils/badge-colors'
137
138
  import { pluralize } from '../../utils/pluralize'
138
139
  import { getPodGpuCount, getNodeGpuCount } from '../../utils/extended-resources'
139
140
  import { type CustomColumnDef, type CustomColumnSource, customColumnKey, readCustomColumnValue, sanitizeCustomColumnDefs } from '../../utils/custom-columns'
140
141
  import { FreshnessControl, type FreshnessConnection } from '../ui/FreshnessControl'
141
142
  import { Tooltip } from '../ui/Tooltip'
142
143
  import { AuditBadgeTooltip, type AuditBadgeMessage } from '../audit/AuditBadgeTooltip'
144
+ import { SEVERITY_TEXT_CLASS } from '../checks/severity'
143
145
  // CRD-specific cell components (extracted)
144
146
  import { GitRepositoryCell, OCIRepositoryCell, HelmRepositoryCell, KustomizationCell, FluxHelmReleaseCell, FluxAlertCell } from './renderers/flux-cells'
145
147
  import { ArgoApplicationCell, ArgoApplicationSetCell, ArgoAppProjectCell } from './renderers/argo-cells'
@@ -1829,8 +1831,8 @@ interface ResourcesViewData {
1829
1831
  onNavigate?: (path: string, options?: { replace?: boolean }) => void
1830
1832
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1831
1833
  certExpiryError?: boolean
1832
- // Cluster Audit findings for the listed kind, keyed by "namespace/name" (the
1833
- // list shows one kind at a time, so ns/name is unambiguous). Host-injected.
1834
+ /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
1835
+ * counts render danger as High and warning as Medium. */
1834
1836
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1835
1837
  onOpenLogs?: (params: { namespace: string; podName: string; containers: string[]; containerName?: string }) => void
1836
1838
  onOpenWorkloadLogs?: (params: { namespace: string; workloadKind: string; workloadName: string }) => void
@@ -1889,7 +1891,8 @@ interface ResourcesViewProps {
1889
1891
  topNodeMetrics?: TopNodeMetrics[]
1890
1892
  certExpiry?: Record<string, { expired?: boolean; daysLeft: number }>
1891
1893
  certExpiryError?: boolean
1892
- // Cluster Audit findings for the selected kind, keyed by "namespace/name".
1894
+ /** Cluster Audit findings keyed by "namespace/name". Raw compatibility
1895
+ * counts render danger as High and warning as Medium. */
1893
1896
  auditBadges?: Record<string, { danger: number; warning: number; messages?: AuditBadgeMessage[] }>
1894
1897
  // Pinned kinds
1895
1898
  pinned?: Array<{ name: string; kind: string; group: string }>
@@ -2183,6 +2186,15 @@ export function ResourcesView({
2183
2186
  setBulkForceDelete(false)
2184
2187
  }, [selectedKind.name, selectedKind.group]) // eslint-disable-line react-hooks/exhaustive-deps
2185
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 === '')
2186
2198
  const [regexMode, setRegexMode] = useState(initialFilters.regex)
2187
2199
  const [sortColumn, setSortColumn] = useState<string | null>(null)
2188
2200
  const [sortDirection, setSortDirection] = useState<SortDirection>(null)
@@ -2961,8 +2973,17 @@ export function ResourcesView({
2961
2973
  setOwnerName(newFilters.ownerName)
2962
2974
  }
2963
2975
 
2964
- // Update search if it changed
2965
- 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) {
2966
2987
  setSearchTerm(newFilters.search)
2967
2988
  }
2968
2989
 
@@ -3171,8 +3192,8 @@ export function ResourcesView({
3171
3192
  shouldPushHistory.current = false
3172
3193
  prevSelectedResourceRef.current = current
3173
3194
 
3174
- updateURL(selectedKind, searchTerm, regexMode, columnFilters, columnFilterExcludes, problemFilters, showInactiveReplicaSets, selectedResource?.namespace, selectedResource?.name, pushHistory)
3175
- }, [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])
3176
3197
 
3177
3198
  // Handle resource click from URL on mount
3178
3199
  useEffect(() => {
@@ -3603,13 +3624,13 @@ export function ResourcesView({
3603
3624
  // rows shown) rather than zero results, so the table doesn't flash empty
3604
3625
  // while the user is mid-typing a pattern.
3605
3626
  const searchRegex = useMemo<{ re: RegExp | null; error: string | null }>(() => {
3606
- if (!regexMode || !searchTerm) return { re: null, error: null }
3627
+ if (!regexMode || !deferredSearchTerm) return { re: null, error: null }
3607
3628
  try {
3608
- return { re: new RegExp(searchTerm, 'i'), error: null }
3629
+ return { re: new RegExp(deferredSearchTerm, 'i'), error: null }
3609
3630
  } catch (e) {
3610
3631
  return { re: null, error: e instanceof Error ? e.message : 'Invalid regex' }
3611
3632
  }
3612
- }, [regexMode, searchTerm])
3633
+ }, [regexMode, deferredSearchTerm])
3613
3634
 
3614
3635
  // Filter resources by search term, status, problems, and sort
3615
3636
  const filteredResources = useMemo(() => {
@@ -3618,7 +3639,7 @@ export function ResourcesView({
3618
3639
  let result = resources
3619
3640
 
3620
3641
  // Apply search filter
3621
- if (searchTerm) {
3642
+ if (deferredSearchTerm) {
3622
3643
  if (regexMode) {
3623
3644
  const re = searchRegex.re
3624
3645
  if (re) {
@@ -3628,7 +3649,7 @@ export function ResourcesView({
3628
3649
  )
3629
3650
  }
3630
3651
  } else {
3631
- const term = searchTerm.toLowerCase()
3652
+ const term = deferredSearchTerm.toLowerCase()
3632
3653
  result = result.filter((r: any) =>
3633
3654
  r.metadata?.name?.toLowerCase().includes(term) ||
3634
3655
  r.metadata?.namespace?.toLowerCase().includes(term)
@@ -3790,7 +3811,7 @@ export function ResourcesView({
3790
3811
  }
3791
3812
 
3792
3813
  return result
3793
- }, [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])
3794
3815
 
3795
3816
  // For nodes table: compute the majority minor version so outliers can be highlighted
3796
3817
  const majorityNodeMinorVersion = useMemo(() => {
@@ -3917,6 +3938,16 @@ export function ResourcesView({
3917
3938
 
3918
3939
  const isCheckboxMode = canBulkSelect && bulkMode
3919
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
+
3920
3951
  // Filter columns by visibility
3921
3952
  const columns = useMemo(() => {
3922
3953
  if (visibleColumns.size === 0) return allColumns.filter(c => c.defaultVisible !== false)
@@ -5043,12 +5074,12 @@ export function ResourcesView({
5043
5074
  isChecked={checkedResources.has(resourceKey)}
5044
5075
  showCheckbox={isCheckboxMode}
5045
5076
  majorityNodeMinorVersion={majorityNodeMinorVersion}
5046
- onClick={() => compareMode ? toggleComparePick(resource) : isCheckboxMode ? toggleChecked(resource) : selectResource(resource, isSelected)}
5047
- onMouseEnter={() => setHighlightedIndex(-1)}
5077
+ onRowClick={handleRowClick}
5078
+ onRowMouseEnter={handleRowMouseEnter}
5048
5079
  compareMode={compareMode}
5049
5080
  comparePickIndex={pickIdx}
5050
5081
  rowHref={rowHrefFor?.(resource)}
5051
- onCheckToggle={() => toggleChecked(resource)}
5082
+ onRowCheckToggle={toggleChecked}
5052
5083
  />
5053
5084
  )
5054
5085
  }}
@@ -5198,8 +5229,11 @@ interface ResourceRowCellsProps {
5198
5229
  isChecked?: boolean
5199
5230
  showCheckbox?: boolean
5200
5231
  majorityNodeMinorVersion?: string
5201
- onClick?: () => void
5202
- 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
5203
5237
  compareMode?: boolean
5204
5238
  /** -1 when not picked; 0 = pick A; 1 = pick B. */
5205
5239
  comparePickIndex?: number
@@ -5207,7 +5241,7 @@ interface ResourceRowCellsProps {
5207
5241
  * data cells drop their click handlers. The compare-mode chip column
5208
5242
  * is unaffected (still toggles picks). */
5209
5243
  rowHref?: string
5210
- onCheckToggle?: () => void
5244
+ onRowCheckToggle?: (resource: any) => void
5211
5245
  }
5212
5246
 
5213
5247
  function rowHighlightClass(
@@ -5229,9 +5263,11 @@ function rowHighlightClass(
5229
5263
  return 'group-hover/row:bg-theme-surface/50'
5230
5264
  }
5231
5265
 
5232
- 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) {
5233
5267
  const rowHighlight = rowHighlightClass(compareMode, comparePickIndex, isSelected, isHighlighted, isChecked)
5234
5268
  const pickedSide = comparePickIndex === 0 ? 'a' : comparePickIndex === 1 ? 'b' : null
5269
+ const onClick = onRowClick ? () => onRowClick(resource, !!isSelected) : undefined
5270
+ const onMouseEnter = onRowMouseEnter
5235
5271
  // When the host supplies an anchor, drop per-cell onClick for the data
5236
5272
  // columns: the anchor is the only navigation surface. The compare-mode
5237
5273
  // chip column keeps its onClick so pick toggling still works.
@@ -5266,7 +5302,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
5266
5302
  {showCheckbox && (
5267
5303
  <td
5268
5304
  className={clsx('border-b-subtle text-center px-0 py-3 w-10 cursor-pointer transition-colors', rowHighlight)}
5269
- onClick={(e) => { e.stopPropagation(); onCheckToggle?.() }}
5305
+ onClick={(e) => { e.stopPropagation(); onRowCheckToggle?.(resource) }}
5270
5306
  >
5271
5307
  <input
5272
5308
  type="checkbox"
@@ -5302,7 +5338,7 @@ function ResourceRowCells({ resource, kind, group, columns, extraColumnsByKey, h
5302
5338
  {hasSpacerColumn && <td className="border-b-subtle p-0" />}
5303
5339
  </>
5304
5340
  )
5305
- }
5341
+ })
5306
5342
 
5307
5343
  const VirtuosoTableRow = React.forwardRef<HTMLTableRowElement, React.HTMLAttributes<HTMLTableRowElement>>(
5308
5344
  function VirtuosoTableRow(props, ref) {
@@ -5381,8 +5417,8 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5381
5417
  {auditTotal > 0 && audit && (
5382
5418
  <Tooltip content={audit.messages && audit.messages.length > 0
5383
5419
  ? <AuditBadgeTooltip messages={audit.messages} />
5384
- : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} danger` : ''}`}>
5385
- <span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT.error : SEVERITY_TEXT.warning)}>
5420
+ : `${auditTotal} audit ${auditTotal === 1 ? 'finding' : 'findings'}${audit.danger > 0 ? ` · ${audit.danger} high` : ''}${audit.warning > 0 ? ` · ${audit.warning} medium` : ''}`}>
5421
+ <span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ? SEVERITY_TEXT_CLASS.high : SEVERITY_TEXT_CLASS.medium)}>
5386
5422
  <AlertTriangle className="w-3 h-3" />
5387
5423
  {auditTotal}
5388
5424
  </span>
@@ -6030,27 +6066,91 @@ function PodCell({ resource, column }: { resource: any; column: string }) {
6030
6066
  const ip = resource.status?.podIP || '-'
6031
6067
  return <span className="text-sm text-theme-text-secondary font-mono">{ip}</span>
6032
6068
  }
6033
- case 'cpu': {
6034
- const key = `${resource.metadata?.namespace}/${resource.metadata?.name}`
6035
- const m = metrics.pods.get(key)
6036
- if (!m || m.cpu === 0) return <span className="text-sm text-theme-text-tertiary">-</span>
6037
- const denom = m.cpuLimit || m.cpuRequest
6038
- if (!denom) return <span className="text-sm text-theme-text-secondary font-mono">{formatCPU(m.cpu)}</span>
6039
- const pct = (m.cpu / denom) * 100
6040
- const marker = m.cpuLimit > 0 && m.cpuRequest > 0 ? (m.cpuRequest / m.cpuLimit) * 100 : undefined
6041
- const tip = buildResourceTooltip('CPU', m.cpu, m.cpuRequest, m.cpuLimit, formatCPU)
6042
- return <ResourceBar used={formatCPU(m.cpu)} total={formatCPU(denom)} percent={pct} colorScheme={getBulletBarScheme(pct, marker)} markerPercent={marker} tooltip={tip} />
6043
- }
6069
+ case 'cpu':
6044
6070
  case 'memory': {
6071
+ const kind = column as 'cpu' | 'memory'
6072
+ const isCPU = kind === 'cpu'
6045
6073
  const key = `${resource.metadata?.namespace}/${resource.metadata?.name}`
6046
6074
  const m = metrics.pods.get(key)
6047
- if (!m || m.memory === 0) return <span className="text-sm text-theme-text-tertiary">-</span>
6048
- const denom = m.memoryLimit || m.memoryRequest
6049
- if (!denom) return <span className="text-sm text-theme-text-secondary font-mono">{formatMemoryShort(m.memory)}</span>
6050
- const pct = (m.memory / denom) * 100
6051
- const marker = m.memoryLimit > 0 && m.memoryRequest > 0 ? (m.memoryRequest / m.memoryLimit) * 100 : undefined
6052
- const tip = buildResourceTooltip('Memory', m.memory, m.memoryRequest, m.memoryLimit, formatMemoryShort)
6053
- 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
+ )
6054
6154
  }
6055
6155
  case 'gpu': {
6056
6156
  const count = getPodGpuCount(resource)
@@ -6265,7 +6365,7 @@ function ServiceCell({ resource, column }: { resource: any; column: string }) {
6265
6365
  const flagged = auditBadges?.[`${meta.namespace || ''}/${meta.name}`]
6266
6366
  if (flagged && flagged.danger + flagged.warning > 0) {
6267
6367
  return (
6268
- <span className={clsx('badge', flagged.danger > 0 ? 'status-unhealthy' : 'status-degraded')}>
6368
+ <span className={clsx('badge', flagged.danger > 0 ? 'status-alert' : 'status-degraded')}>
6269
6369
  No endpoints
6270
6370
  </span>
6271
6371
  )
@@ -6573,65 +6673,176 @@ function getBulletBarScheme(_usagePct: number, _markerPct: number | undefined):
6573
6673
  return 'utilization'
6574
6674
  }
6575
6675
 
6576
- function buildResourceTooltip(
6577
- type: 'CPU' | 'Memory',
6578
- usage: number,
6579
- request: number,
6580
- limit: number,
6581
- formatFn: (n: number) => string,
6582
- ) {
6583
- const isCPU = type === 'CPU'
6584
-
6585
- let guidance: string
6586
- if (limit > 0 && request > 0) {
6587
- const pctOfLimit = (usage / limit) * 100
6588
- if (pctOfLimit > 90) {
6589
- guidance = isCPU
6590
- ? 'Near the limit — CPU may be throttled'
6591
- : 'Near the limit — at risk of OOM kill'
6592
- } else if (usage > request) {
6593
- guidance = 'Exceeds request — consider raising it if sustained'
6594
- } else {
6595
- guidance = 'Below request — healthy headroom'
6596
- }
6597
- } else if (limit > 0) {
6598
- const pctOfLimit = (usage / limit) * 100
6599
- if (pctOfLimit > 90) {
6600
- guidance = isCPU
6601
- ? 'Near the limit — CPU may be throttled'
6602
- : '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
6603
6740
  } else {
6604
- guidance = 'No request set — scheduling may be suboptimal'
6741
+ unlimitedCount++
6742
+ }
6743
+ if (request > 0) {
6744
+ requestedCount++
6745
+ summedRequest += request
6605
6746
  }
6606
- } else if (request > 0) {
6607
- guidance = usage > request
6608
- ? `Exceeds request with no limit — unbounded ${isCPU ? 'CPU' : 'memory'} access`
6609
- : 'No limit set — pod can burst beyond request'
6610
- } else {
6611
- guidance = 'No request or limit configured'
6612
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
6613
6802
 
6614
6803
  return (
6615
- <div className="whitespace-normal w-52 flex flex-col gap-1.5 py-0.5">
6616
- <div className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-0.5 text-xs font-mono">
6617
- <span className="text-theme-text-tertiary">Usage</span>
6618
- <span className="text-theme-text-primary">{formatFn(usage)}</span>
6619
- {request > 0 && (
6620
- <>
6621
- <span className="text-theme-text-tertiary">Request</span>
6622
- <span className="text-theme-text-primary">{formatFn(request)}</span>
6623
- </>
6624
- )}
6625
- {limit > 0 && (
6626
- <>
6627
- <span className="text-theme-text-tertiary">Limit</span>
6628
- <span className="text-theme-text-primary">{formatFn(limit)}</span>
6629
- </>
6630
- )}
6631
- </div>
6632
- <div className="text-[11px] text-theme-text-secondary border-t border-theme-border/50 pt-1">
6633
- {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
+ })}
6634
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
+ )}
6635
6846
  </div>
6636
6847
  )
6637
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 = {