@skyhook-io/k8s-ui 1.17.1 → 1.18.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/README.md +13 -0
- package/package.json +4 -4
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +33 -4
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
- package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
- package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
- package/src/components/gitops/insights/insights-helpers.ts +7 -0
- package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
- package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
- package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
- package/src/components/gitops/tree/tree-helpers.ts +5 -1
- package/src/components/issues/IssuesView.tsx +18 -10
- package/src/components/issues/ResourceIssuesSection.tsx +5 -2
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/index.ts +1 -1
- package/src/components/issues/issues.test.ts +26 -0
- package/src/components/issues/severity.ts +9 -2
- package/src/components/issues/types.ts +32 -4
- package/src/components/logs/LogCore.theme.test.tsx +126 -1
- package/src/components/logs/LogCore.tsx +159 -40
- package/src/components/logs/LogsViewer.test.tsx +183 -60
- package/src/components/logs/LogsViewer.tsx +49 -12
- package/src/components/logs/StructuredLogLine.tsx +1 -1
- package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
- package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
- package/src/components/logs/useLogBuffer.ts +48 -18
- package/src/components/logs/useLogSearch.ts +3 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
- package/src/components/resources/ColumnPickerList.test.tsx +46 -0
- package/src/components/resources/ColumnPickerList.tsx +174 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +36 -21
- package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +198 -93
- package/src/components/resources/column-order.test.ts +104 -0
- package/src/components/resources/column-order.ts +64 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
- package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
- package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
- package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
- package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
- package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +72 -20
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +15 -2
- package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
- package/src/components/shared/DetailShell.tsx +9 -7
- package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
- package/src/components/shared/DrainPlanDialog.tsx +6 -2
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +24 -4
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +12 -6
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
- package/src/components/timeline/DiffViewer.test.tsx +69 -0
- package/src/components/timeline/DiffViewer.tsx +113 -4
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/timeline/shared.test.tsx +48 -0
- package/src/components/timeline/shared.tsx +14 -19
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +6 -4
- package/src/components/ui/Toast.tsx +48 -30
- package/src/components/ui/drawer-components.test.tsx +253 -0
- package/src/components/ui/drawer-components.tsx +315 -11
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/ui/index.ts +1 -1
- package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +68 -14
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +91 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops-insights.ts +1 -0
- package/src/types/gitops-tree.ts +2 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +22 -0
- package/src/utils/index.ts +2 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +56 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
|
@@ -21,6 +21,70 @@ const pod = {
|
|
|
21
21
|
status: { phase: 'Running' },
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
describe('PodRenderer completed readiness conditions', () => {
|
|
25
|
+
const completionConditions = [
|
|
26
|
+
{ type: 'PodReadyToStartContainers', status: 'False' },
|
|
27
|
+
{ type: 'Initialized', status: 'True', reason: 'PodCompleted' },
|
|
28
|
+
{ type: 'Ready', status: 'False', reason: 'PodCompleted' },
|
|
29
|
+
{ type: 'ContainersReady', status: 'False', reason: 'PodCompleted' },
|
|
30
|
+
{ type: 'PodScheduled', status: 'True' },
|
|
31
|
+
]
|
|
32
|
+
const renderConditions = (phase: string | undefined, conditions = completionConditions) => renderToString(
|
|
33
|
+
<PodRenderer
|
|
34
|
+
data={{ ...pod, status: { phase, conditions } }}
|
|
35
|
+
onCopy={() => undefined}
|
|
36
|
+
copied={null}
|
|
37
|
+
/>,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
it('keeps completed readiness observations visible without counting them as failing', () => {
|
|
41
|
+
const html = renderConditions('Succeeded')
|
|
42
|
+
|
|
43
|
+
expect(html).toContain('Conditions (5)')
|
|
44
|
+
expect(html).not.toContain('failing')
|
|
45
|
+
expect(html).toContain('PodReadyToStartContainers')
|
|
46
|
+
expect(html).toContain('ContainersReady')
|
|
47
|
+
expect(html).toContain('PodCompleted')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('uses successful completion even when readiness reasons are stale or absent', () => {
|
|
51
|
+
const html = renderConditions('Succeeded', [
|
|
52
|
+
{ type: 'PodReadyToStartContainers', status: 'False' },
|
|
53
|
+
{ type: 'Ready', status: 'False', reason: 'ContainersNotReady' },
|
|
54
|
+
{ type: 'ContainersReady', status: 'False' },
|
|
55
|
+
])
|
|
56
|
+
|
|
57
|
+
expect(html).toContain('Conditions (3)')
|
|
58
|
+
expect(html).not.toContain('failing')
|
|
59
|
+
expect(html).toContain('ContainersNotReady')
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it.each(['Running', 'Failed', undefined])('retains readiness failures when phase is %s', (phase) => {
|
|
63
|
+
const html = renderConditions(phase, [
|
|
64
|
+
{ type: 'PodReadyToStartContainers', status: 'False' },
|
|
65
|
+
{ type: 'Ready', status: 'False', reason: 'PodFailed' },
|
|
66
|
+
{ type: 'ContainersReady', status: 'False' },
|
|
67
|
+
])
|
|
68
|
+
|
|
69
|
+
expect(html).toContain('Conditions (3) · 3 failing')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('does not let a completion reason hide readiness failures on a failed Pod', () => {
|
|
73
|
+
expect(renderConditions('Failed')).toContain('Conditions (5) · 3 failing')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('retains unrelated false conditions after successful completion', () => {
|
|
77
|
+
const html = renderConditions('Succeeded', [
|
|
78
|
+
...completionConditions,
|
|
79
|
+
{ type: 'example.com/controller-ready', status: 'False', reason: 'ControllerFailed' },
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
expect(html).toContain('Conditions (6) · 1 failing')
|
|
83
|
+
expect(html).toContain('example.com/controller-ready')
|
|
84
|
+
expect(html).toContain('ControllerFailed')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
24
88
|
describe('PodRenderer envFrom expansion', () => {
|
|
25
89
|
it('keeps same-name ConfigMap and Secret values separate', () => {
|
|
26
90
|
const resolvedEnvFrom: ResolvedEnvFrom = {
|
|
@@ -482,3 +546,54 @@ it('shows reported running-but-unready nginx readiness as degraded', () => {
|
|
|
482
546
|
expect(html).toMatch(/bg-amber-100[^>]*>Not Ready</)
|
|
483
547
|
expect(html).toMatch(/>running</)
|
|
484
548
|
})
|
|
549
|
+
|
|
550
|
+
describe('finished Pod resource usage',()=>{
|
|
551
|
+
it.each(['Succeeded','Failed'])('explains ended live usage for %s without cluster repair advice',phase=>{
|
|
552
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase}}} onCopy={()=>{}} copied={null} metricsUnavailable metricsHistory={{containers:[],metricsUnavailableReason:'not found'}} />)
|
|
553
|
+
expect(html).toContain('Live resource usage has ended for this Pod')
|
|
554
|
+
expect(html).toContain('No recorded usage is available.')
|
|
555
|
+
expect(html).not.toContain('Metrics collection error:')
|
|
556
|
+
expect(html).not.toContain('Radar cannot read metrics.k8s.io')
|
|
557
|
+
expect(html).not.toContain('No usage yet: this Pod is not running')
|
|
558
|
+
})
|
|
559
|
+
it('keeps a genuine collector error qualified as current on a finished Pod',()=>{
|
|
560
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null} metricsHistory={{containers:[],collectionError:'forbidden: list pods.metrics.k8s.io'}} />)
|
|
561
|
+
expect(html).toContain('Current cluster metrics collection error:')
|
|
562
|
+
expect(html).toContain('forbidden: list pods.metrics.k8s.io')
|
|
563
|
+
expect(html).toContain('Live resource usage has ended for this Pod')
|
|
564
|
+
})
|
|
565
|
+
it('keeps recorded history and hides cached live usage after completion',()=>{
|
|
566
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null}
|
|
567
|
+
metrics={{timestamp:'2026-10-25T12:00:00Z',containers:[{name:'cached-live',usage:{cpu:'100m',memory:'1Mi'}}]}}
|
|
568
|
+
metricsHistory={{containers:[{name:'recorded-history',dataPoints:[{timestamp:'2026-10-25T11:59:00Z',cpu:100,memory:1024}]}]}} />)
|
|
569
|
+
expect(html).toContain('recorded-history')
|
|
570
|
+
expect(html).not.toContain('cached-live')
|
|
571
|
+
expect(html).not.toContain('Last updated:')
|
|
572
|
+
})
|
|
573
|
+
it('distinguishes loading from a loaded empty terminal history',()=>{
|
|
574
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null} metricsHistoryLoading />)
|
|
575
|
+
expect(html).toContain('Loading recorded usage...')
|
|
576
|
+
expect(html).not.toContain('No recorded usage is available.')
|
|
577
|
+
})
|
|
578
|
+
it('shows a failed terminal history fetch without claiming empty history',()=>{
|
|
579
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Failed'}}} onCopy={()=>{}} copied={null} metricsHistoryError={new Error('Metrics history not available')} />)
|
|
580
|
+
expect(html).toContain('Recorded usage could not be loaded')
|
|
581
|
+
expect(html).toContain('Metrics history not available')
|
|
582
|
+
expect(html).not.toContain('No recorded usage is available.')
|
|
583
|
+
})
|
|
584
|
+
it('retains cached history when its refresh fails',()=>{
|
|
585
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null}
|
|
586
|
+
metricsHistory={{containers:[{name:'recorded-history',dataPoints:[{timestamp:'2026-10-25T11:59:00Z',cpu:100,memory:1024}]}]}}
|
|
587
|
+
metricsHistoryError={new Error('Network request failed')} />)
|
|
588
|
+
expect(html).toContain('Recorded usage could not be refreshed')
|
|
589
|
+
expect(html).toContain('recorded-history')
|
|
590
|
+
expect(html).not.toContain('No recorded usage is available.')
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
it('keeps usage absent when the host wires no metrics state',()=>{
|
|
594
|
+
const html=renderToString(<PodRenderer data={{...pod,status:{phase:'Succeeded'}}} onCopy={()=>{}} copied={null} />)
|
|
595
|
+
expect(html).not.toContain('Resource Usage')
|
|
596
|
+
expect(html).not.toContain('Live resource usage has ended')
|
|
597
|
+
})
|
|
598
|
+
|
|
599
|
+
})
|
|
@@ -4,7 +4,7 @@ import { clsx } from 'clsx'
|
|
|
4
4
|
import { PolicySection } from './PolicySection'
|
|
5
5
|
import type { PolicyResourceResponse } from '../../../types/policy'
|
|
6
6
|
import { Section, PropertyList, Property, ConditionsSection, CopyHandler, AlertBanner, ResourceLink, useOperationalIssuesShown } from '../../ui/drawer-components'
|
|
7
|
-
import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName } from '../resource-utils'
|
|
7
|
+
import { formatResources, formatDuration, getPodProblems, getPodPhaseDisplay, healthColors, SEVERITY_DOT_COLOR, getDefaultContainerName, getExecContainerNames } from '../resource-utils'
|
|
8
8
|
import { getResourceStatusColor, SEVERITY_BORDER } from '../../../utils/badge-colors'
|
|
9
9
|
import {
|
|
10
10
|
rbacVerbBadgeClass,
|
|
@@ -87,11 +87,13 @@ interface PodRendererProps {
|
|
|
87
87
|
// Metrics data injection
|
|
88
88
|
metrics?: { containers?: any[]; timestamp?: string }
|
|
89
89
|
metricsHistory?: { metricsAPIReachable?: boolean; containers?: any[]; collectionError?: string; metricsUnavailableReason?: string; metricsUnavailableDiagnosis?: string }
|
|
90
|
+
metricsHistoryLoading?: boolean
|
|
91
|
+
metricsHistoryError?: Error | null
|
|
90
92
|
metricsUnavailable?: boolean
|
|
91
93
|
hideMetricsServer?: boolean
|
|
92
94
|
// Filesystem browser render props
|
|
93
95
|
renderImageBrowser?: (props: { image: string; namespace: string; podName: string; pullSecrets: string[]; onClose: () => void; onSwitchToPodFiles?: () => void }) => ReactNode
|
|
94
|
-
renderPodBrowser?: (props: { namespace: string; podName: string; containers: string[]; initialContainer: string; onClose: () => void; onSwitchToImageFiles: () => void }) => ReactNode
|
|
96
|
+
renderPodBrowser?: (props: { namespace: string; podName: string; containers: string[]; initialContainer: string; onClose: () => void; onSwitchToImageFiles: (containerName?: string) => void }) => ReactNode
|
|
95
97
|
/**
|
|
96
98
|
* Resolved content for envFrom references.
|
|
97
99
|
* When provided, expands ConfigMap/Secret keys inline instead of showing "(all keys)".
|
|
@@ -286,6 +288,8 @@ export function PodRenderer({
|
|
|
286
288
|
renderPortAction,
|
|
287
289
|
metrics,
|
|
288
290
|
metricsHistory,
|
|
291
|
+
metricsHistoryLoading,
|
|
292
|
+
metricsHistoryError,
|
|
289
293
|
metricsUnavailable,
|
|
290
294
|
hideMetricsServer,
|
|
291
295
|
renderImageBrowser,
|
|
@@ -320,6 +324,7 @@ export function PodRenderer({
|
|
|
320
324
|
const namespace = data.metadata?.namespace
|
|
321
325
|
const podName = data.metadata?.name
|
|
322
326
|
const isRunning = data.status?.phase === 'Running'
|
|
327
|
+
const isTerminal = data.status?.phase === 'Succeeded' || data.status?.phase === 'Failed'
|
|
323
328
|
|
|
324
329
|
// Check for problems. Suppressed when the detail already shows the dedicated
|
|
325
330
|
// Operational Issues section (the Issues pipeline covers the same pod failures,
|
|
@@ -329,9 +334,10 @@ export function PodRenderer({
|
|
|
329
334
|
const hasProblems = podProblems.length > 0 && !operationalIssuesShown
|
|
330
335
|
const notRunning = data.status?.phase === 'Pending' && ![...containerStatuses, ...initContainerStatuses].some((s: any) => s.state?.running)
|
|
331
336
|
const noUsageYet = notRunning && metricsHistory?.metricsAPIReachable === true && !metricsHistory.collectionError && !metricsHistory.containers?.length && !metrics?.containers?.length
|
|
332
|
-
const showMetricsUnavailable = !!metricsUnavailable && !metricsHistory?.collectionError && !noUsageYet
|
|
337
|
+
const showMetricsUnavailable = !isTerminal && !!metricsUnavailable && !metricsHistory?.collectionError && !noUsageYet
|
|
333
338
|
const hasMetricsHistory = !!metricsHistory?.containers?.length
|
|
334
|
-
const
|
|
339
|
+
const showTerminalUsage = isTerminal && (metricsHistory !== undefined || metricsHistoryLoading || !!metricsHistoryError || !!metrics || metricsUnavailable)
|
|
340
|
+
const currentMetrics = isTerminal || metricsUnavailable ? undefined : metrics
|
|
335
341
|
|
|
336
342
|
// Image filesystem modal state
|
|
337
343
|
const [selectedImage, setSelectedImage] = useState<string | null>(null)
|
|
@@ -340,6 +346,16 @@ export function PodRenderer({
|
|
|
340
346
|
// Pod filesystem modal state
|
|
341
347
|
const [podFilesContainer, setPodFilesContainer] = useState<string | null>(null)
|
|
342
348
|
|
|
349
|
+
// A sidecar can be running while the pod is still Pending, and a stopped
|
|
350
|
+
// container has no live files, so match the image against running containers.
|
|
351
|
+
const runningImageContainer = selectedImage
|
|
352
|
+
? getExecContainerNames(data).find((name) => {
|
|
353
|
+
const spec = [...containers, ...initContainers].find((c: any) => c.name === name)
|
|
354
|
+
const status = [...containerStatuses, ...initContainerStatuses].find((st: any) => st.name === name)
|
|
355
|
+
return spec?.image === selectedImage && !!status?.state?.running
|
|
356
|
+
})
|
|
357
|
+
: undefined
|
|
358
|
+
|
|
343
359
|
const handleOpenTerminal = (containerName?: string) => {
|
|
344
360
|
const container = containerName || getDefaultContainerName(data)
|
|
345
361
|
if (namespace && podName && container) {
|
|
@@ -347,7 +363,7 @@ export function PodRenderer({
|
|
|
347
363
|
namespace,
|
|
348
364
|
podName,
|
|
349
365
|
containerName: container,
|
|
350
|
-
containers:
|
|
366
|
+
containers: getExecContainerNames(data),
|
|
351
367
|
})
|
|
352
368
|
}
|
|
353
369
|
}
|
|
@@ -512,6 +528,7 @@ export function PodRenderer({
|
|
|
512
528
|
const isFailed = stateKey === 'terminated' && exitCode != null && exitCode !== 0
|
|
513
529
|
const isWaiting = stateKey === 'waiting'
|
|
514
530
|
const isInitRunning = stateKey === 'running'
|
|
531
|
+
const isSidecar = container.restartPolicy === 'Always'
|
|
515
532
|
|
|
516
533
|
// Status label and color
|
|
517
534
|
let statusLabel: string
|
|
@@ -553,6 +570,26 @@ export function PodRenderer({
|
|
|
553
570
|
<span className="text-sm font-medium text-theme-text-primary">{container.name}</span>
|
|
554
571
|
</div>
|
|
555
572
|
<div className="flex items-center gap-2">
|
|
573
|
+
{isSidecar && isInitRunning && canExec && onOpenTerminal && (
|
|
574
|
+
<Tooltip content="Terminal" delay={150}>
|
|
575
|
+
<button
|
|
576
|
+
onClick={() => handleOpenTerminal(container.name)}
|
|
577
|
+
className="p-1 text-slate-400 hover:text-blue-400 hover:bg-slate-600/50 rounded transition-colors"
|
|
578
|
+
>
|
|
579
|
+
<TerminalIcon className="w-4 h-4" />
|
|
580
|
+
</button>
|
|
581
|
+
</Tooltip>
|
|
582
|
+
)}
|
|
583
|
+
{isSidecar && isInitRunning && canExec && renderPodBrowser && (
|
|
584
|
+
<Tooltip content="Browse files" delay={150}>
|
|
585
|
+
<button
|
|
586
|
+
onClick={() => setPodFilesContainer(container.name)}
|
|
587
|
+
className="p-1 text-slate-400 hover:text-blue-400 hover:bg-slate-600/50 rounded transition-colors"
|
|
588
|
+
>
|
|
589
|
+
<FolderOpen className="w-4 h-4" />
|
|
590
|
+
</button>
|
|
591
|
+
</Tooltip>
|
|
592
|
+
)}
|
|
556
593
|
{canViewLogs && (
|
|
557
594
|
<Tooltip content="Logs" delay={150}>
|
|
558
595
|
<button
|
|
@@ -841,15 +878,30 @@ export function PodRenderer({
|
|
|
841
878
|
)}
|
|
842
879
|
|
|
843
880
|
{/* Resource Usage (from metrics-server) — hidden when Prometheus has CPU/memory data */}
|
|
844
|
-
{!hideMetricsServer && !!(currentMetrics?.containers?.length || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable || noUsageYet) && (
|
|
881
|
+
{!hideMetricsServer && !!(showTerminalUsage || currentMetrics?.containers?.length || hasMetricsHistory || metricsHistory?.collectionError || showMetricsUnavailable || noUsageYet) && (
|
|
845
882
|
<Section title="Resource Usage" icon={Activity} defaultExpanded>
|
|
883
|
+
{isTerminal && (
|
|
884
|
+
<div className="mb-3 text-sm text-theme-text-tertiary">
|
|
885
|
+
Live resource usage has ended for this Pod.{metricsHistory && !hasMetricsHistory && !metricsHistoryLoading && !metricsHistoryError && ' No recorded usage is available.'}
|
|
886
|
+
</div>
|
|
887
|
+
)}
|
|
888
|
+
{isTerminal && metricsHistoryLoading && (
|
|
889
|
+
<p className="mb-3 text-sm text-theme-text-tertiary">Loading recorded usage...</p>
|
|
890
|
+
)}
|
|
891
|
+
{isTerminal && metricsHistoryError && (
|
|
892
|
+
<AlertBanner
|
|
893
|
+
variant="warning"
|
|
894
|
+
title={metricsHistory ? 'Recorded usage could not be refreshed' : 'Recorded usage could not be loaded'}
|
|
895
|
+
message={metricsHistoryError.message}
|
|
896
|
+
/>
|
|
897
|
+
)}
|
|
846
898
|
{noUsageYet && <MetricsUnavailableNotice noUsageYet />}
|
|
847
899
|
{showMetricsUnavailable && (
|
|
848
900
|
<MetricsUnavailableNotice rawError={metricsHistory?.metricsUnavailableReason} diagnosis={metricsHistory?.metricsUnavailableDiagnosis} />
|
|
849
901
|
)}
|
|
850
902
|
{metricsHistory?.collectionError && (
|
|
851
903
|
<div className="mb-3 rounded-lg border border-yellow-500/30 bg-yellow-500/5 px-3 py-2 text-xs text-yellow-400">
|
|
852
|
-
<span className="font-medium">Metrics collection error
|
|
904
|
+
<span className="font-medium">{isTerminal ? 'Current cluster metrics collection error:' : 'Metrics collection error:'}</span>{' '}
|
|
853
905
|
<span className="break-all">{metricsHistory.collectionError}</span>
|
|
854
906
|
</div>
|
|
855
907
|
)}
|
|
@@ -939,14 +991,16 @@ export function PodRenderer({
|
|
|
939
991
|
</Section>
|
|
940
992
|
)}
|
|
941
993
|
|
|
942
|
-
{/*
|
|
943
|
-
|
|
944
|
-
so tone it neutral (gray) instead of red. Gated on the PodCompleted reason
|
|
945
|
-
so a genuinely not-ready pod (any other reason) still reads red. */}
|
|
994
|
+
{/* Sandbox teardown and lost serving readiness are expected after success;
|
|
995
|
+
custom conditions retain their own meaning. */}
|
|
946
996
|
<ConditionsSection
|
|
947
997
|
conditions={data.status?.conditions}
|
|
948
998
|
getConditionTone={(cond) =>
|
|
949
|
-
|
|
999
|
+
data.status?.phase === 'Succeeded'
|
|
1000
|
+
&& cond?.status === 'False'
|
|
1001
|
+
&& ['Ready', 'ContainersReady', 'PodReadyToStartContainers'].includes(cond?.type)
|
|
1002
|
+
? 'unknown'
|
|
1003
|
+
: undefined
|
|
950
1004
|
}
|
|
951
1005
|
/>
|
|
952
1006
|
|
|
@@ -973,10 +1027,8 @@ export function PodRenderer({
|
|
|
973
1027
|
podName: podName || '',
|
|
974
1028
|
pullSecrets: imagePullSecrets,
|
|
975
1029
|
onClose: () => setSelectedImage(null),
|
|
976
|
-
onSwitchToPodFiles:
|
|
977
|
-
|
|
978
|
-
const match = containers.find((c: any) => c.image === selectedImage)
|
|
979
|
-
setPodFilesContainer(match?.name || containers[0]?.name)
|
|
1030
|
+
onSwitchToPodFiles: canExec && renderPodBrowser && (isRunning || runningImageContainer) ? () => {
|
|
1031
|
+
setPodFilesContainer(runningImageContainer || containers[0]?.name)
|
|
980
1032
|
} : undefined,
|
|
981
1033
|
})}
|
|
982
1034
|
|
|
@@ -984,12 +1036,12 @@ export function PodRenderer({
|
|
|
984
1036
|
{podFilesContainer && renderPodBrowser && renderPodBrowser({
|
|
985
1037
|
namespace: namespace || '',
|
|
986
1038
|
podName: podName || '',
|
|
987
|
-
containers:
|
|
1039
|
+
containers: getExecContainerNames(data),
|
|
988
1040
|
initialContainer: podFilesContainer,
|
|
989
1041
|
onClose: () => setPodFilesContainer(null),
|
|
990
|
-
onSwitchToImageFiles: () => {
|
|
991
|
-
|
|
992
|
-
const match = containers.find((c: any) => c.name ===
|
|
1042
|
+
onSwitchToImageFiles: (containerName?: string) => {
|
|
1043
|
+
const selected = containerName || podFilesContainer
|
|
1044
|
+
const match = [...containers, ...initContainers].find((c: any) => c.name === selected)
|
|
993
1045
|
if (match?.image) setSelectedImage(match.image)
|
|
994
1046
|
},
|
|
995
1047
|
})}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { searchTrivyFindings, trivyReportImageRef } from './resource-utils-trivy'
|
|
3
|
+
|
|
4
|
+
const vuln = {
|
|
5
|
+
metadata: { name: 'replicaset-billing-5cfccf7776-php-nginx', namespace: 'legacy' },
|
|
6
|
+
report: {
|
|
7
|
+
registry: { server: 'index.docker.io' },
|
|
8
|
+
artifact: { repository: 'trafex/php-nginx', tag: '3.6.0', digest: 'sha256:abc' },
|
|
9
|
+
vulnerabilities: [
|
|
10
|
+
{ vulnerabilityID: 'CVE-2025-0001', resource: 'libssl3', installedVersion: '3.3.2-r0', fixedVersion: '3.3.3-r0' },
|
|
11
|
+
{ vulnerabilityID: 'CVE-2026-109618', resource: 'php83', installedVersion: '8.3.20-r0', fixedVersion: '8.3.36-r0' },
|
|
12
|
+
{ vulnerabilityID: 'CVE-2026-0002', resource: 'busybox', installedVersion: '1.36.1-r29' },
|
|
13
|
+
],
|
|
14
|
+
},
|
|
15
|
+
}
|
|
16
|
+
const secrets = {
|
|
17
|
+
metadata: { name: 'replicaset-billing-secrets', namespace: 'legacy' },
|
|
18
|
+
report: { secrets: [{ ruleID: 'aws-access-key-id', target: '/var/www/html/.env', match: 'AWS_ACCESS_KEY_ID=AKIAREVIEWFIXTURE01' }] },
|
|
19
|
+
}
|
|
20
|
+
const audit = {
|
|
21
|
+
metadata: { name: 'replicaset-api-8467889d75', namespace: 'team-b' },
|
|
22
|
+
report: { checks: [{ checkID: 'KSV012', success: false }, { checkID: 'KSV001', success: true }] },
|
|
23
|
+
}
|
|
24
|
+
const sbom = {
|
|
25
|
+
metadata: { name: 'replicaset-web-sbom', namespace: 'shop' },
|
|
26
|
+
report: { components: { components: [{ name: 'guzzlehttp/guzzle', version: '7.8.1' }] } },
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe('searchTrivyFindings', () => {
|
|
30
|
+
it('matches a CVE and names the finding with its versions', () => {
|
|
31
|
+
const { matches, truncated } = searchTrivyFindings([vuln, secrets], 'cve-2026-109618')
|
|
32
|
+
expect(truncated).toBe(false)
|
|
33
|
+
expect([...matches.keys()]).toEqual([vuln])
|
|
34
|
+
expect(matches.get(vuln)).toEqual({ site: 'finding', text: 'CVE-2026-109618 php83 8.3.20-r0 → 8.3.36-r0' })
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('matches packages, unfixed findings, SBOM components and failed checks', () => {
|
|
38
|
+
const text = (r: any, term: string) => searchTrivyFindings([r], term).matches.get(r)?.text
|
|
39
|
+
expect(text(vuln, 'php83')).toContain('php83')
|
|
40
|
+
expect(text(vuln, 'busybox')).toBe('CVE-2026-0002 busybox 1.36.1-r29 → unfixed')
|
|
41
|
+
expect(text(sbom, 'guzzle')).toBe('guzzlehttp/guzzle 7.8.1')
|
|
42
|
+
expect(text(audit, 'ksv012')).toBe('KSV012')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('tells an image match apart from a finding', () => {
|
|
46
|
+
expect(searchTrivyFindings([vuln], 'sha256:abc').matches.get(vuln)).toEqual({ site: 'image', text: 'index.docker.io/trafex/php-nginx:3.6.0@sha256:abc' })
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('treats the term as text, not a pattern', () => {
|
|
50
|
+
expect(searchTrivyFindings([vuln], '8.3.20-r0 →').matches.has(vuln)).toBe(true)
|
|
51
|
+
expect(searchTrivyFindings([vuln], 'php8.').matches.size).toBe(0)
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('caps a finding at 256 UTF-8 bytes for phrases, like server search', () => {
|
|
55
|
+
const wide = { metadata: { name: 'r', namespace: 'n' }, report: { components: { components: [{ name: 'é'.repeat(128), version: 'needle' }] } } }
|
|
56
|
+
expect(searchTrivyFindings([wide], 'é needle').matches.size).toBe(0)
|
|
57
|
+
expect(searchTrivyFindings([wide], 'needle').matches.size).toBe(1)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('matches a phrase across one finding, never across findings', () => {
|
|
61
|
+
expect(searchTrivyFindings([vuln], 'php83 8.3.20').matches.has(vuln)).toBe(true)
|
|
62
|
+
expect(searchTrivyFindings([vuln], 'libssl3 8.3.20').matches.has(vuln)).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('never searches a passing check or a secret finding\'s matched text', () => {
|
|
66
|
+
expect(searchTrivyFindings([audit], 'ksv001').matches.size).toBe(0)
|
|
67
|
+
expect(searchTrivyFindings([secrets], 'akiareview').matches.size).toBe(0)
|
|
68
|
+
expect(searchTrivyFindings([secrets], 'aws-access-key-id').matches.get(secrets)?.text).toBe('aws-access-key-id /var/www/html/.env')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('skips rows whose name or namespace already match', () => {
|
|
72
|
+
expect(searchTrivyFindings([vuln], 'legacy').matches.size).toBe(0)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('stops at the findings limit and says the search was cut short', () => {
|
|
76
|
+
const { matches, truncated } = searchTrivyFindings([vuln, vuln], 'cve-2026-0002', 4)
|
|
77
|
+
expect(truncated).toBe(true)
|
|
78
|
+
expect(matches.size).toBe(1)
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('trivyReportImageRef', () => {
|
|
83
|
+
it('renders registry/repository:tag@digest like server search', () => {
|
|
84
|
+
expect(trivyReportImageRef(vuln)).toBe('index.docker.io/trafex/php-nginx:3.6.0@sha256:abc')
|
|
85
|
+
expect(trivyReportImageRef({ report: {} })).toBe('')
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -55,6 +55,128 @@ export function getVulnerabilityReportContainer(report: any): string {
|
|
|
55
55
|
return report.metadata?.labels?.['trivy-operator.container.name'] || '-'
|
|
56
56
|
}
|
|
57
57
|
|
|
58
|
+
// ============================================================================
|
|
59
|
+
// TRIVY OPERATOR — TABLE SEARCH OVER FINDINGS
|
|
60
|
+
// ============================================================================
|
|
61
|
+
|
|
62
|
+
export const TRIVY_GROUP = 'aquasecurity.github.io'
|
|
63
|
+
|
|
64
|
+
// Bounds one table search over findings. The filter runs on the main thread,
|
|
65
|
+
// and a cluster can hold thousands of reports with hundreds of findings each.
|
|
66
|
+
const MAX_TABLE_FINDINGS = 200_000
|
|
67
|
+
|
|
68
|
+
// Server search caps one indexed finding at 256 bytes (internal/search/trivy.go).
|
|
69
|
+
const MAX_FINDING_LABEL = 256
|
|
70
|
+
|
|
71
|
+
export interface TrivyFindingMatch {
|
|
72
|
+
site: 'finding' | 'image'
|
|
73
|
+
text: string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface TrivyFindingsSearch {
|
|
77
|
+
// Rows that matched only through a finding or the scanned image.
|
|
78
|
+
matches: Map<object, TrivyFindingMatch>
|
|
79
|
+
truncated: boolean
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function str(v: unknown): string {
|
|
83
|
+
return typeof v === 'string' ? v : ''
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function asArray(v: unknown): any[] {
|
|
87
|
+
return Array.isArray(v) ? v : []
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// findingLabel joins a finding's parts and caps it at MAX_FINDING_LABEL
|
|
91
|
+
// UTF-8 bytes on a character boundary, as server search does, so a phrase
|
|
92
|
+
// that falls past the cap matches in neither place.
|
|
93
|
+
function findingLabel(...parts: string[]): string {
|
|
94
|
+
const label = parts.filter(Boolean).join(' ')
|
|
95
|
+
// A UTF-16 unit is at most 3 UTF-8 bytes, so short labels need no count.
|
|
96
|
+
if (label.length * 3 <= MAX_FINDING_LABEL) return label
|
|
97
|
+
let bytes = 0
|
|
98
|
+
let end = 0
|
|
99
|
+
for (const ch of label) {
|
|
100
|
+
const cp = ch.codePointAt(0) ?? 0
|
|
101
|
+
const size = cp < 0x80 ? 1 : cp < 0x800 ? 2 : cp < 0x10000 ? 3 : 4
|
|
102
|
+
if (bytes + size > MAX_FINDING_LABEL) break
|
|
103
|
+
bytes += size
|
|
104
|
+
end += ch.length
|
|
105
|
+
}
|
|
106
|
+
return label.slice(0, end)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// trivyReportImageRef renders a report's scanned image the way search
|
|
110
|
+
// indexes it: registry/repository:tag@digest.
|
|
111
|
+
export function trivyReportImageRef(resource: any): string {
|
|
112
|
+
const report = resource?.report
|
|
113
|
+
const repo = str(report?.artifact?.repository)
|
|
114
|
+
if (!repo) return ''
|
|
115
|
+
const registry = str(report?.registry?.server)
|
|
116
|
+
let ref = registry ? `${registry}/${repo}` : repo
|
|
117
|
+
const tag = str(report?.artifact?.tag)
|
|
118
|
+
if (tag) ref += `:${tag}`
|
|
119
|
+
const digest = str(report?.artifact?.digest)
|
|
120
|
+
if (digest) ref += `@${digest}`
|
|
121
|
+
return ref
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// searchTrivyFindings finds reports whose findings contain term, over the
|
|
125
|
+
// entries server search indexes (internal/search/trivy.go): CVE IDs with
|
|
126
|
+
// package and versions, SBOM components, failed check IDs, secret rule IDs
|
|
127
|
+
// and targets, plus the scanned image. A secret finding's `match` is the
|
|
128
|
+
// matched line itself and is never searched. A term with a space matches
|
|
129
|
+
// within one finding, never across two. Rows whose name or namespace already
|
|
130
|
+
// contain term are skipped, since they match without a finding.
|
|
131
|
+
export function searchTrivyFindings(resources: readonly any[], term: string, limit = MAX_TABLE_FINDINGS): TrivyFindingsSearch {
|
|
132
|
+
const matches = new Map<object, TrivyFindingMatch>()
|
|
133
|
+
const re = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'i')
|
|
134
|
+
const spaced = term.includes(' ')
|
|
135
|
+
const hit = (...parts: string[]): boolean =>
|
|
136
|
+
spaced ? re.test(findingLabel(...parts)) : parts.some((p) => p !== '' && re.test(p))
|
|
137
|
+
let left = limit
|
|
138
|
+
for (const r of resources) {
|
|
139
|
+
const meta = r?.metadata
|
|
140
|
+
if (re.test(str(meta?.name)) || re.test(str(meta?.namespace))) continue
|
|
141
|
+
const report = r?.report
|
|
142
|
+
if (!report || typeof report !== 'object') continue
|
|
143
|
+
const image = trivyReportImageRef(r)
|
|
144
|
+
if (image && re.test(image)) {
|
|
145
|
+
matches.set(r, { site: 'image', text: image })
|
|
146
|
+
continue
|
|
147
|
+
}
|
|
148
|
+
let found = ''
|
|
149
|
+
let truncated = false
|
|
150
|
+
scan: {
|
|
151
|
+
for (const v of asArray(report.vulnerabilities)) {
|
|
152
|
+
if (--left < 0) { truncated = true; break scan }
|
|
153
|
+
const id = str(v?.vulnerabilityID)
|
|
154
|
+
if (!id) continue
|
|
155
|
+
const parts = [id, str(v.resource), str(v.installedVersion), '→', str(v.fixedVersion) || 'unfixed']
|
|
156
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
157
|
+
}
|
|
158
|
+
for (const c of asArray(report.components?.components)) {
|
|
159
|
+
if (--left < 0) { truncated = true; break scan }
|
|
160
|
+
const parts = [str(c?.name), str(c?.version)]
|
|
161
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
162
|
+
}
|
|
163
|
+
for (const c of asArray(report.checks)) {
|
|
164
|
+
if (--left < 0) { truncated = true; break scan }
|
|
165
|
+
if (c?.success !== false) continue
|
|
166
|
+
if (hit(str(c.checkID))) { found = findingLabel(str(c.checkID)); break scan }
|
|
167
|
+
}
|
|
168
|
+
for (const sec of asArray(report.secrets)) {
|
|
169
|
+
if (--left < 0) { truncated = true; break scan }
|
|
170
|
+
const parts = [str(sec?.ruleID), str(sec?.target)]
|
|
171
|
+
if (hit(...parts)) { found = findingLabel(...parts); break scan }
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (found) matches.set(r, { site: 'finding', text: found })
|
|
175
|
+
if (truncated) return { matches, truncated: true }
|
|
176
|
+
}
|
|
177
|
+
return { matches, truncated: false }
|
|
178
|
+
}
|
|
179
|
+
|
|
58
180
|
// ============================================================================
|
|
59
181
|
// TRIVY OPERATOR — CONFIG AUDIT REPORT UTILITIES
|
|
60
182
|
// ============================================================================
|
|
@@ -106,7 +106,7 @@ export function summarizeSchedulerMessage(message?: string, options: { plain?: b
|
|
|
106
106
|
[/^(\d+) (?:node\(s\) )?Insufficient memory(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough memory'],
|
|
107
107
|
[/^(\d+) node\(s\) (?:didn.t match Pod.s node affinity\/selector|node affinity\/selector mismatch)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the Pod’s node affinity or selector'],
|
|
108
108
|
[/^(\d+) node\(s\) (?:had )?untolerated taint(?:\(s\))?(?: \{[^}]*\}| [^\s]+)?(?: \(0\/\d+ nodes available\))?$/i, 'have taints the Pod does not tolerate'],
|
|
109
|
-
[/^(\d+) node\(s\) (?:had )?volume node[- ]affinity conflict(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
|
|
109
|
+
[/^(\d+) node\(s\) (?:(?:had )?volume node[- ]affinity conflict|didn.t match PersistentVolume.s node affinity)(?: \(0\/\d+ nodes available\))?$/i, 'do not match the volume’s node affinity'],
|
|
110
110
|
]
|
|
111
111
|
return splitSchedulerClauses(m).filter((part) => part.trim()).map((part) => {
|
|
112
112
|
const clause = part.trim()
|
|
@@ -738,6 +738,19 @@ export function getDefaultContainerName(pod: any): string | undefined {
|
|
|
738
738
|
return containers[0]?.name
|
|
739
739
|
}
|
|
740
740
|
|
|
741
|
+
/**
|
|
742
|
+
* The containers a terminal can target: the regular containers, then native
|
|
743
|
+
* sidecars (init containers with restartPolicy: Always, Kubernetes 1.29+),
|
|
744
|
+
* which keep running for the pod's lifetime like regular containers.
|
|
745
|
+
*/
|
|
746
|
+
export function getExecContainerNames(pod: any): string[] {
|
|
747
|
+
const regular = (pod?.spec?.containers || []).map((c: any) => c.name)
|
|
748
|
+
const sidecars = (pod?.spec?.initContainers || [])
|
|
749
|
+
.filter((c: any) => c.restartPolicy === 'Always')
|
|
750
|
+
.map((c: any) => c.name)
|
|
751
|
+
return [...regular, ...sidecars].filter(Boolean)
|
|
752
|
+
}
|
|
753
|
+
|
|
741
754
|
export function getContainerSquareStates(pod: any): ContainerSquareState[] {
|
|
742
755
|
const result: ContainerSquareState[] = []
|
|
743
756
|
const initStatuses = pod.status?.initContainerStatuses || []
|
|
@@ -1314,7 +1327,7 @@ export function getCronJobStatus(cj: any): StatusBadge {
|
|
|
1314
1327
|
if (activeJobs > 0) {
|
|
1315
1328
|
return { text: `Active (${activeJobs})`, color: healthColors.neutral, level: 'neutral' }
|
|
1316
1329
|
}
|
|
1317
|
-
return { text: '
|
|
1330
|
+
return { text: 'Enabled', color: healthColors.neutral, level: 'neutral' }
|
|
1318
1331
|
}
|
|
1319
1332
|
|
|
1320
1333
|
export function getCronJobSchedule(cj: any): { cron: string; readable: string } {
|
|
@@ -44,6 +44,7 @@ it.each([
|
|
|
44
44
|
['2 node(s) node affinity/selector mismatch (0/2 nodes available)', 'both nodes do not match the Pod’s node affinity or selector'],
|
|
45
45
|
['unbound PersistentVolumeClaim, 1 node(s) volume node-affinity conflict (0/2 nodes available)', 'a required volume claim is not bound; 1 node does not match the volume’s node affinity'],
|
|
46
46
|
['0/2 nodes are available: 1 node(s) had volume node affinity conflict, 1 Insufficient memory.', '1 node does not match the volume’s node affinity; 1 node does not have enough memory'],
|
|
47
|
+
["0/3 nodes are available: 1 node(s) didn't satisfy existing pods anti-affinity rules, 2 node(s) didn't match PersistentVolume's node affinity.", "1 node(s) didn't satisfy existing pods anti-affinity rules; 2 nodes do not match the volume’s node affinity"],
|
|
47
48
|
['no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node(s) insufficient cpu (0/3 nodes available)', 'no node has kubernetes.io/arch=arm64 — 2 node(s) carry kubernetes.io/arch: [amd64]; 1 node does not have enough CPU'],
|
|
48
49
|
['custom scheduler says 2 Insufficient cpu, 1 Insufficient memory', 'custom scheduler says 2 Insufficient cpu; 1 node does not have enough memory'],
|
|
49
50
|
['1 node(s) pod anti-affinity conflict, 1 node(s) insufficient memory, no node has zone=west — 2 node(s) carry zone: [east, north]', '1 node(s) pod anti-affinity conflict; 1 node does not have enough memory; no node has zone=west — 2 node(s) carry zone: [east, north]'],
|
|
@@ -77,22 +77,24 @@ export function DetailShell<TId extends string = string>({
|
|
|
77
77
|
<div className="shrink-0 border-b border-theme-border bg-theme-base">
|
|
78
78
|
{!compactHeader && (
|
|
79
79
|
<>
|
|
80
|
-
{breadcrumb && <div className="px-6 pt-2.5">{breadcrumb}</div>}
|
|
81
|
-
|
|
80
|
+
{breadcrumb && <div className="px-6 pt-2.5 phone:px-4">{breadcrumb}</div>}
|
|
81
|
+
{/* Phones always wrap: the identity takes the row beside the back
|
|
82
|
+
button and the actions drop below it. */}
|
|
83
|
+
<div className={clsx('px-6 flex items-start gap-4 phone:px-4 phone:flex-wrap phone:gap-x-3 phone:gap-y-2', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
|
|
82
84
|
{nav}
|
|
83
85
|
{/* Wrapping on, the identity's basis is its one-line width, so the
|
|
84
86
|
actions move below whenever the whole title line would not fit
|
|
85
87
|
beside them — never squeezing the name to keep them up. */}
|
|
86
|
-
<div className={clsx('min-w-0', wrapHeader ? 'flex-auto' : 'flex-1')}>{identity}</div>
|
|
88
|
+
<div className={clsx('min-w-0 phone:basis-[calc(100%-3rem)]', wrapHeader ? 'flex-auto' : 'flex-1')}>{identity}</div>
|
|
87
89
|
{wrapHeader ? <div className="ml-auto flex shrink-0 items-start gap-4">{headerActions}</div> : headerActions}
|
|
88
90
|
</div>
|
|
89
|
-
{subheader && <div className="-mt-1 px-6 pb-3">{subheader}</div>}
|
|
91
|
+
{subheader && <div className="-mt-1 px-6 pb-3 phone:px-4">{subheader}</div>}
|
|
90
92
|
</>
|
|
91
93
|
)}
|
|
92
94
|
|
|
93
95
|
{/* Tabs (left) + scope controls / actions (right) */}
|
|
94
|
-
<div className={clsx('flex items-center', compactHeader ? 'px-0' : 'border-t border-theme-border px-6')}>
|
|
95
|
-
<div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto', compact ? 'gap-0' : 'gap-1')} role="tablist">
|
|
96
|
+
<div className={clsx('flex items-center phone:flex-wrap', compactHeader ? 'px-0' : 'border-t border-theme-border px-6 phone:px-4')}>
|
|
97
|
+
<div ref={stripRef} className={clsx('flex min-w-0 flex-auto overflow-x-auto phone:basis-full', compact ? 'gap-0' : 'gap-1')} role="tablist">
|
|
96
98
|
{visibleTabs.map((t) => (
|
|
97
99
|
<DetailShellTabButton key={t.id} active={activeTab === t.id} compact={compact} onClick={() => onTabChange(t.id)}>
|
|
98
100
|
{!compact && t.icon}
|
|
@@ -102,7 +104,7 @@ export function DetailShell<TId extends string = string>({
|
|
|
102
104
|
))}
|
|
103
105
|
</div>
|
|
104
106
|
{(scopeControls || tabStripEnd) && (
|
|
105
|
-
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2">
|
|
107
|
+
<div className="ml-auto flex min-w-0 flex-wrap items-center justify-end gap-2 pl-2 phone:py-2">
|
|
106
108
|
{scopeControls}
|
|
107
109
|
{tabStripEnd}
|
|
108
110
|
</div>
|
|
@@ -108,6 +108,13 @@ describe('DrainPlanContent', () => {
|
|
|
108
108
|
expect(html).toContain('About this estimate')
|
|
109
109
|
})
|
|
110
110
|
|
|
111
|
+
it('distinguishes expected exclusions from protected workloads before draining', () => {
|
|
112
|
+
const html = render({ plan: plan([pod('agent', 'skip', { expectedSkip: true }), pod('bare', 'skip', { expectedSkip: false })]) })
|
|
113
|
+
expect(html).toContain('left by design')
|
|
114
|
+
expect(html).toContain('requires attention')
|
|
115
|
+
expect(html).toContain('bare reason')
|
|
116
|
+
})
|
|
117
|
+
|
|
111
118
|
it('offers a refresh only when the host can recompute the plan', () => {
|
|
112
119
|
const p = plan([pod('web', 'evict')])
|
|
113
120
|
expect(render({ plan: p, onRefreshPlan: noop })).toContain('Recompute the plan')
|