@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.
- package/package.json +1 -1
- package/src/components/applications/ApplicationDetail.test.tsx +28 -0
- package/src/components/applications/ApplicationDetail.tsx +17 -0
- package/src/components/audit/AuditAlerts.tsx +13 -11
- package/src/components/audit/AuditBadgeTooltip.test.tsx +6 -0
- package/src/components/audit/AuditBadgeTooltip.tsx +9 -6
- package/src/components/audit/AuditCard.tsx +15 -13
- package/src/components/audit/AuditFindingsTable.tsx +33 -31
- package/src/components/audit/AuditSeverityPresentation.test.tsx +64 -0
- package/src/components/checks/types.ts +4 -4
- package/src/components/issues/types.ts +4 -0
- 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 +310 -99
- package/src/components/resources/renderers/PodRenderer.tsx +4 -1
- package/src/components/timeline/TimelineList.tsx +7 -2
- package/src/components/timeline/TimelineSwimlanes.tsx +37 -19
- package/src/components/timeline/index.ts +0 -2
- package/src/components/timeline/timeline-live.test.ts +0 -37
- package/src/components/timeline/timeline-live.ts +2 -24
- package/src/components/topology/K8sResourceNode.tsx +11 -9
- package/src/components/ui/FilterPill.tsx +3 -1
- package/src/hooks/index.ts +1 -0
- package/src/hooks/useDebouncedValue.ts +29 -0
- package/src/types/core.ts +17 -4
|
@@ -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
|
|
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
|
-
|
|
1833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
3175
|
-
}, [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])
|
|
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 || !
|
|
3627
|
+
if (!regexMode || !deferredSearchTerm) return { re: null, error: null }
|
|
3607
3628
|
try {
|
|
3608
|
-
return { re: new RegExp(
|
|
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,
|
|
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 (
|
|
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 =
|
|
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,
|
|
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
|
-
|
|
5047
|
-
|
|
5077
|
+
onRowClick={handleRowClick}
|
|
5078
|
+
onRowMouseEnter={handleRowMouseEnter}
|
|
5048
5079
|
compareMode={compareMode}
|
|
5049
5080
|
comparePickIndex={pickIdx}
|
|
5050
5081
|
rowHref={rowHrefFor?.(resource)}
|
|
5051
|
-
|
|
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
|
-
|
|
5202
|
-
|
|
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
|
-
|
|
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,
|
|
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();
|
|
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}
|
|
5385
|
-
<span className={clsx('shrink-0 inline-flex items-center gap-0.5 text-[10px] font-medium cursor-help', audit.danger > 0 ?
|
|
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
|
|
6048
|
-
const
|
|
6049
|
-
|
|
6050
|
-
|
|
6051
|
-
|
|
6052
|
-
|
|
6053
|
-
|
|
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-
|
|
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
|
-
|
|
6577
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
6616
|
-
<div className="
|
|
6617
|
-
|
|
6618
|
-
|
|
6619
|
-
|
|
6620
|
-
|
|
6621
|
-
|
|
6622
|
-
|
|
6623
|
-
|
|
6624
|
-
|
|
6625
|
-
|
|
6626
|
-
|
|
6627
|
-
|
|
6628
|
-
|
|
6629
|
-
|
|
6630
|
-
|
|
6631
|
-
|
|
6632
|
-
|
|
6633
|
-
|
|
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,
|
|
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 = {
|