@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.
Files changed (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. 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 currentMetrics = metricsUnavailable ? undefined : metrics
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: containers.map((c: { name: string }) => c.name),
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:</span>{' '}
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
- {/* Conditions. A completed pod's Ready/ContainersReady flip to False with
943
- reason "PodCompleted" — that's expected for a finished pod, not a failure,
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
- cond?.status === 'False' && cond?.reason === 'PodCompleted' ? 'unknown' : undefined
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: isRunning && canExec && renderPodBrowser ? () => {
977
- // Find which container uses this image and open pod files for it
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: containers.map((c: { name: string }) => c.name),
1039
+ containers: getExecContainerNames(data),
988
1040
  initialContainer: podFilesContainer,
989
1041
  onClose: () => setPodFilesContainer(null),
990
- onSwitchToImageFiles: () => {
991
- // Find which image this container uses and open image filesystem
992
- const match = containers.find((c: any) => c.name === podFilesContainer)
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: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
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
- <div className={clsx('px-6 flex items-start gap-4', wrapHeader && 'flex-wrap gap-y-2', breadcrumb ? 'pb-3 pt-1.5' : 'py-3')}>
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')