@skyhook-io/k8s-ui 1.17.0 → 1.17.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 (227) hide show
  1. package/README.md +41 -0
  2. package/package.json +3 -3
  3. package/src/components/charts/AreaChart.interaction.test.tsx +118 -0
  4. package/src/components/charts/AreaChart.test.tsx +25 -0
  5. package/src/components/charts/AreaChart.tsx +141 -12
  6. package/src/components/charts/index.ts +1 -0
  7. package/src/components/checks/ChecksView.tsx +18 -1
  8. package/src/components/cnpg/CNPGBackupSummary.tsx +57 -9
  9. package/src/components/cnpg/CNPGClusterHASection.test.tsx +90 -0
  10. package/src/components/cnpg/CNPGClusterHASection.tsx +331 -0
  11. package/src/components/cnpg/CNPGClusterSummary.test.tsx +275 -0
  12. package/src/components/cnpg/CNPGClusterSummary.tsx +310 -96
  13. package/src/components/cnpg/CNPGConnectSection.test.tsx +64 -0
  14. package/src/components/cnpg/CNPGConnectSection.tsx +178 -0
  15. package/src/components/cnpg/CNPGDeclarativeSummary.tsx +157 -26
  16. package/src/components/cnpg/CNPGImageCatalogSummary.tsx +7 -4
  17. package/src/components/cnpg/CNPGLogicalPath.test.tsx +22 -0
  18. package/src/components/cnpg/CNPGLogicalPath.tsx +150 -0
  19. package/src/components/cnpg/CNPGObjectStoreSummary.tsx +9 -6
  20. package/src/components/cnpg/CNPGObjectSummary.test.tsx +193 -2
  21. package/src/components/cnpg/CNPGPoolerScheduling.test.tsx +36 -0
  22. package/src/components/cnpg/CNPGPoolerSummary.tsx +241 -14
  23. package/src/components/cnpg/CNPGSharedSummary.tsx +15 -8
  24. package/src/components/cnpg/CNPGWALArchivingFact.test.tsx +39 -0
  25. package/src/components/cnpg/backupRuns.test.ts +17 -0
  26. package/src/components/cnpg/backupRuns.ts +19 -0
  27. package/src/components/cnpg/connect.test.ts +124 -0
  28. package/src/components/cnpg/connect.ts +155 -0
  29. package/src/components/cnpg/databaseRole.test.ts +38 -0
  30. package/src/components/cnpg/databaseRole.ts +80 -0
  31. package/src/components/cnpg/ha.test.ts +446 -0
  32. package/src/components/cnpg/ha.ts +621 -0
  33. package/src/components/cnpg/index.ts +18 -0
  34. package/src/components/cnpg/logicalReplication.test.ts +121 -0
  35. package/src/components/cnpg/logicalReplication.ts +252 -0
  36. package/src/components/cnpg/pooler.test.ts +84 -0
  37. package/src/components/cnpg/pooler.ts +225 -0
  38. package/src/components/cnpg/primitives.tsx +21 -117
  39. package/src/components/cnpg/problems.ts +12 -0
  40. package/src/components/cnpg/relations.test.ts +63 -13
  41. package/src/components/cnpg/relations.ts +61 -52
  42. package/src/components/cnpg/schedule.ts +35 -0
  43. package/src/components/cnpg/workspace-disk.test.ts +97 -0
  44. package/src/components/cnpg/workspace-fleet-metrics.test.ts +250 -0
  45. package/src/components/cnpg/workspace-problems.test.ts +175 -0
  46. package/src/components/cnpg/workspace.test.ts +380 -9
  47. package/src/components/cnpg/workspace.ts +1137 -136
  48. package/src/components/dock/DockContext.test.tsx +32 -0
  49. package/src/components/dock/DockContext.tsx +18 -4
  50. package/src/components/dock/LocalTerminalTab.test.tsx +40 -0
  51. package/src/components/dock/LocalTerminalTab.tsx +4 -3
  52. package/src/components/dock/TerminalTab.tsx +20 -8
  53. package/src/components/facts/certainty.tsx +36 -0
  54. package/src/components/facts/facts.test.tsx +24 -0
  55. package/src/components/facts/facts.tsx +62 -0
  56. package/src/components/facts/index.ts +6 -0
  57. package/src/components/facts/managed-by.test.tsx +17 -0
  58. package/src/components/facts/managed-by.tsx +36 -0
  59. package/src/components/facts/public-exports.test.tsx +35 -0
  60. package/src/components/gitops/GitOpsGraphFilterRail.tsx +6 -2
  61. package/src/components/gitops/GitOpsHealthSourceNotice.test.tsx +38 -0
  62. package/src/components/gitops/GitOpsHealthSourceNotice.tsx +13 -3
  63. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +115 -0
  64. package/src/components/gitops/insights/ArgoResourceDiff.tsx +22 -5
  65. package/src/components/gitops/insights/DriftPanel.tsx +62 -0
  66. package/src/components/gitops/insights/GitOpsInsightViews.tsx +24 -71
  67. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +26 -1
  68. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +38 -55
  69. package/src/components/gitops/tree/layout.test.ts +111 -0
  70. package/src/components/gitops/tree/layout.ts +128 -0
  71. package/src/components/gitops/tree/tree-helpers.test.ts +17 -0
  72. package/src/components/gitops/tree/tree-helpers.ts +7 -1
  73. package/src/components/issues/ConditionalCleanup.test.tsx +82 -0
  74. package/src/components/issues/IssuesView.tsx +40 -5
  75. package/src/components/issues/ResourceIssuesSection.test.tsx +28 -0
  76. package/src/components/issues/ResourceIssuesSection.tsx +3 -0
  77. package/src/components/issues/index.ts +3 -1
  78. package/src/components/issues/issue-timing.ts +12 -1
  79. package/src/components/issues/issues.test.ts +49 -1
  80. package/src/components/issues/severity.ts +21 -0
  81. package/src/components/issues/types.ts +23 -0
  82. package/src/components/logs/LogCore.theme.test.tsx +81 -3
  83. package/src/components/logs/LogCore.tsx +25 -17
  84. package/src/components/logs/LogToolbarSelects.tsx +3 -1
  85. package/src/components/logs/LogsViewer.test.tsx +291 -0
  86. package/src/components/logs/LogsViewer.tsx +72 -11
  87. package/src/components/logs/StructuredLogLine.test.tsx +76 -0
  88. package/src/components/logs/StructuredLogLine.tsx +22 -30
  89. package/src/components/logs/WorkloadLogsViewer.test.tsx +65 -0
  90. package/src/components/logs/WorkloadLogsViewer.tsx +39 -10
  91. package/src/components/logs/useLogBuffer.test.ts +8 -0
  92. package/src/components/logs/useLogStream.ts +17 -1
  93. package/src/components/problems/index.ts +1 -0
  94. package/src/components/problems/problems.test.tsx +63 -0
  95. package/src/components/problems/problems.tsx +165 -0
  96. package/src/components/resources/ActiveFilterBar.test.tsx +78 -0
  97. package/src/components/resources/ActiveFilterBar.tsx +86 -0
  98. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +42 -0
  99. package/src/components/resources/ResourcesSidebar.test.tsx +36 -2
  100. package/src/components/resources/ResourcesSidebar.tsx +92 -41
  101. package/src/components/resources/ResourcesView.kind-filter-memory.test.ts +52 -0
  102. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -0
  103. package/src/components/resources/ResourcesView.per-container.test.ts +56 -1
  104. package/src/components/resources/ResourcesView.tsx +302 -109
  105. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  106. package/src/components/resources/renderers/AutoscalingReferences.test.tsx +286 -0
  107. package/src/components/resources/renderers/CAPIClusterRenderer.tsx +3 -2
  108. package/src/components/resources/renderers/CAPIExactReferenceNavigation.test.tsx +27 -0
  109. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +23 -0
  110. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +8 -19
  111. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +5 -7
  112. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +25 -0
  113. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.test.tsx +32 -0
  114. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +7 -15
  115. package/src/components/resources/renderers/CompositeRenderer.navigation.test.tsx +35 -0
  116. package/src/components/resources/renderers/CompositeRenderer.tsx +3 -3
  117. package/src/components/resources/renderers/CompositionRenderer.tsx +3 -3
  118. package/src/components/resources/renderers/ContourExactReferenceNavigation.test.tsx +34 -0
  119. package/src/components/resources/renderers/ContourHTTPProxyRenderer.tsx +11 -10
  120. package/src/components/resources/renderers/EndpointSliceRenderer.test.tsx +39 -1
  121. package/src/components/resources/renderers/EndpointSliceRenderer.tsx +14 -13
  122. package/src/components/resources/renderers/HPARenderer.tsx +56 -16
  123. package/src/components/resources/renderers/IngressRenderer.defaultBackend.test.tsx +33 -0
  124. package/src/components/resources/renderers/IngressRenderer.tsx +30 -3
  125. package/src/components/resources/renderers/IstioDestinationRuleRenderer.tsx +9 -9
  126. package/src/components/resources/renderers/IstioExactReferenceNavigation.test.tsx +34 -0
  127. package/src/components/resources/renderers/IstioVirtualServiceRenderer.tsx +5 -8
  128. package/src/components/resources/renderers/KedaScaledObjectRenderer.tsx +13 -11
  129. package/src/components/resources/renderers/KnativeEventingRenderer.tsx +24 -31
  130. package/src/components/resources/renderers/KnativeExactReferenceNavigation.test.tsx +47 -0
  131. package/src/components/resources/renderers/KnativeFlowRenderer.tsx +6 -7
  132. package/src/components/resources/renderers/KnativeNetworkingRenderer.tsx +7 -9
  133. package/src/components/resources/renderers/KnativeReferenceLink.tsx +14 -0
  134. package/src/components/resources/renderers/KnativeReferenceLinks.test.tsx +60 -0
  135. package/src/components/resources/renderers/KnativeSourceRenderer.tsx +9 -11
  136. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +2 -2
  137. package/src/components/resources/renderers/MetricsUnavailableNotice.tsx +3 -2
  138. package/src/components/resources/renderers/PodRenderer.test.tsx +32 -0
  139. package/src/components/resources/renderers/PodRenderer.tsx +41 -29
  140. package/src/components/resources/renderers/SimpleRouteRenderer.tsx +1 -1
  141. package/src/components/resources/renderers/VPARenderer.tsx +17 -11
  142. package/src/components/resources/resource-utils-capi.ts +1 -1
  143. package/src/components/resources/resource-utils-cnpg.test.ts +35 -10
  144. package/src/components/resources/resource-utils-cnpg.ts +51 -20
  145. package/src/components/resources/resource-utils-contour.test.ts +44 -0
  146. package/src/components/resources/resource-utils-contour.ts +16 -0
  147. package/src/components/resources/resource-utils-istio.ts +12 -8
  148. package/src/components/resources/resource-utils-knative.ts +18 -0
  149. package/src/components/resources/resource-utils.ts +82 -6
  150. package/src/components/resources/rules-column-width.test.ts +1 -1
  151. package/src/components/resources/summarize-scheduler-message.test.ts +30 -0
  152. package/src/components/shared/ActionConfirmDialog.test.tsx +154 -0
  153. package/src/components/shared/ActionConfirmDialog.tsx +246 -0
  154. package/src/components/shared/CreateResourceDialog.test.tsx +330 -0
  155. package/src/components/shared/CreateResourceDialog.tsx +126 -80
  156. package/src/components/shared/DetailShell.test.tsx +80 -0
  157. package/src/components/shared/DetailShell.tsx +85 -10
  158. package/src/components/shared/EditableYamlView.test.tsx +7 -0
  159. package/src/components/shared/EditableYamlView.tsx +1 -1
  160. package/src/components/shared/ResourceActionsBar.tsx +11 -4
  161. package/src/components/shared/ResourceRendererDispatch.tsx +12 -9
  162. package/src/components/shared/index.ts +2 -0
  163. package/src/components/timeline/TimelineSwimlanes.tsx +1 -1
  164. package/src/components/timeline/timeline-filters.ts +1 -0
  165. package/src/components/topology/K8sResourceNode.test.ts +18 -1
  166. package/src/components/topology/K8sResourceNode.tsx +44 -2
  167. package/src/components/topology/TopologyFilterSidebar.tsx +1 -0
  168. package/src/components/topology/TopologyGraph.tsx +3 -71
  169. package/src/components/topology/TopologySearch.tsx +1 -0
  170. package/src/components/topology/internet-groups.test.ts +35 -0
  171. package/src/components/topology/internet-groups.ts +55 -0
  172. package/src/components/topology/layout.ts +1 -1
  173. package/src/components/ui/Badge.tsx +1 -0
  174. package/src/components/ui/FoldSection.tsx +68 -0
  175. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +62 -0
  176. package/src/components/ui/ForceDeleteConfirmDialog.tsx +24 -12
  177. package/src/components/ui/RefLink.tsx +20 -0
  178. package/src/components/ui/RowActionMenu.tsx +8 -3
  179. package/src/components/ui/crd-definition-navigation.test.tsx +160 -0
  180. package/src/components/ui/drawer-components.test.tsx +110 -1
  181. package/src/components/ui/drawer-components.tsx +62 -9
  182. package/src/components/ui/index.ts +3 -1
  183. package/src/components/ui/monacoEditorFeatures.ts +77 -0
  184. package/src/components/ui/monacoRuntime.ts +14 -19
  185. package/src/components/ui/status-tone.test.ts +10 -1
  186. package/src/components/ui/status-tone.tsx +29 -0
  187. package/src/components/workload/WorkloadView.deleted.test.tsx +43 -0
  188. package/src/components/workload/WorkloadView.relationships.test.tsx +37 -0
  189. package/src/components/workload/WorkloadView.spec.test.tsx +149 -0
  190. package/src/components/workload/WorkloadView.tsx +180 -64
  191. package/src/index.ts +14 -1
  192. package/src/monaco-deep.d.ts +19 -12
  193. package/src/theme/components.css +13 -0
  194. package/src/types/capacity.ts +11 -2
  195. package/src/types/core.ts +29 -2
  196. package/src/types/gitops-flux-status.test.ts +32 -1
  197. package/src/types/gitops-insights.ts +19 -2
  198. package/src/types/gitops-tree.ts +10 -0
  199. package/src/types/gitops.ts +26 -6
  200. package/src/utils/api-resources.ts +7 -0
  201. package/src/utils/application-history.ts +1 -0
  202. package/src/utils/cnpg-backup.test.ts +26 -0
  203. package/src/utils/cnpg-backup.ts +70 -0
  204. package/src/utils/format.test.ts +9 -0
  205. package/src/utils/format.ts +4 -0
  206. package/src/utils/gitops-owner.ts +3 -2
  207. package/src/utils/grant.test.ts +19 -0
  208. package/src/utils/grant.ts +35 -0
  209. package/src/utils/index.ts +4 -0
  210. package/src/utils/k8s-errors.ts +7 -7
  211. package/src/utils/log-format.test.ts +41 -0
  212. package/src/utils/log-format.ts +71 -15
  213. package/src/utils/log-level.ts +12 -8
  214. package/src/utils/navigation.test.ts +57 -0
  215. package/src/utils/navigation.ts +86 -8
  216. package/src/utils/object-reference-navigation.test.ts +43 -0
  217. package/src/utils/resource-hierarchy.test.ts +37 -0
  218. package/src/utils/resource-hierarchy.ts +15 -7
  219. package/src/utils/resource-icons.ts +2 -0
  220. package/src/utils/skeleton-yaml.test.ts +21 -0
  221. package/src/utils/skeleton-yaml.ts +16 -0
  222. package/src/utils/url-path.test.ts +35 -0
  223. package/src/utils/url-path.ts +21 -0
  224. package/src/utils/validators.test.ts +4 -13
  225. package/src/utils/validators.ts +5 -19
  226. package/src/utils/view-transition.test.ts +35 -1
  227. package/src/utils/view-transition.ts +16 -1
@@ -1,11 +1,13 @@
1
1
  import { Cpu } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
3
  import { Section, PropertyList, Property, ConditionsSection, AlertBanner, ResourceLink } from '../../ui/drawer-components'
4
- import { kindToPlural } from '../../../utils/navigation'
4
+ import { objectReferenceToResourceRef } from '../../../utils/navigation'
5
+ import type { ResourceRef } from '../../../types'
6
+ import { CORE_RESOURCES } from '../../../utils/api-resources'
5
7
 
6
8
  interface VPARendererProps {
7
9
  data: any
8
- onNavigate?: (ref: { kind: string; namespace: string; name: string }) => void
10
+ onNavigate?: (ref: ResourceRef) => void
9
11
  }
10
12
 
11
13
  function formatResource(value: string | undefined): string {
@@ -17,6 +19,16 @@ export function VPARenderer({ data, onNavigate }: VPARendererProps) {
17
19
  const spec = data.spec || {}
18
20
  const status = data.status || {}
19
21
  const targetRef = spec.targetRef || {}
22
+ const namespace = data.metadata?.namespace
23
+ const builtin = !targetRef.apiVersion ? CORE_RESOURCES.find(resource => resource.kind === targetRef.kind) : undefined
24
+ const builtinAPIVersion = builtin
25
+ ? (builtin.group ? `${builtin.group}/${builtin.version}` : builtin.version)
26
+ : targetRef.kind === 'ReplicationController' ? 'v1' : undefined
27
+ const target = objectReferenceToResourceRef({
28
+ ...targetRef,
29
+ apiVersion: targetRef.apiVersion || builtinAPIVersion,
30
+ namespace,
31
+ })
20
32
  const updatePolicy = spec.updatePolicy || {}
21
33
  const resourcePolicy = spec.resourcePolicy || {}
22
34
  const containerPolicies = resourcePolicy.containerPolicies || []
@@ -63,15 +75,9 @@ export function VPARenderer({ data, onNavigate }: VPARendererProps) {
63
75
  <Section title="Configuration" icon={Cpu}>
64
76
  <PropertyList>
65
77
  <Property label="Target" value={
66
- targetRef.name ? (
67
- <ResourceLink
68
- name={targetRef.name}
69
- kind={kindToPlural(targetRef.kind || 'Deployment')}
70
- namespace={data.metadata?.namespace || ''}
71
- label={`${targetRef.kind}/${targetRef.name}`}
72
- onNavigate={onNavigate}
73
- />
74
- ) : undefined
78
+ targetRef.name ? (target ? (
79
+ <ResourceLink {...target} label={`${targetRef.kind}/${targetRef.name}`} onNavigate={onNavigate} />
80
+ ) : `${targetRef.kind || 'Unknown kind'}/${targetRef.name}`) : undefined
75
81
  } />
76
82
  <Property label="Update Mode" value={
77
83
  <span className={clsx(
@@ -73,7 +73,7 @@ export function getClusterStatus(resource: any): StatusBadge {
73
73
  }
74
74
 
75
75
  export function getClusterClass(resource: any): string {
76
- return resource.spec?.topology?.class || '-'
76
+ return resource.spec?.topology?.classRef?.name || resource.spec?.topology?.class || '-'
77
77
  }
78
78
 
79
79
  export function getClusterVersion(resource: any): string {
@@ -14,6 +14,7 @@ import {
14
14
  getCNPGWALArchivingFailure,
15
15
  getCNPGLastBackupFailure,
16
16
  classifyCNPGClusterPhase,
17
+ cnpgBlockedPhaseExplanation,
17
18
  classifyCNPGBackupPhase,
18
19
  CNPG_CLUSTER_PHASES_HEALTHY,
19
20
  CNPG_CLUSTER_PHASES_TRANSIENT,
@@ -115,6 +116,26 @@ describe('getCNPGClusterCertificateExpirations', () => {
115
116
 
116
117
  const cluster = (status: any = {}, spec: any = {}) => ({ spec, status })
117
118
 
119
+ describe('cnpgBlockedPhaseExplanation', () => {
120
+ it('says the operator retries a plugin phase, and quotes its reason', () => {
121
+ const e = cnpgBlockedPhaseExplanation(
122
+ 'Cluster cannot proceed to reconciliation due to an unknown plugin being required',
123
+ "Unknown plugin: 'barman-cloud.cloudnative-pg.io'. Check the operator and plugin logs for errors",
124
+ )
125
+ expect(e.title).toBe('Reconciliation is blocked by a plugin')
126
+ expect(e.body).toMatch(/retries every few seconds/)
127
+ expect(e.body).not.toMatch(/manual intervention|does not resolve/)
128
+ expect(e.body).toMatch(/The operator reports: Unknown plugin/)
129
+ expect(e.body.endsWith('for errors.')).toBe(true)
130
+ expect(e.message.startsWith('Cluster cannot proceed')).toBe(true)
131
+ })
132
+ it('keeps manual intervention for the one phase upstream says needs it', () => {
133
+ expect(cnpgBlockedPhaseExplanation('Cluster is unrecoverable and needs manual intervention').title).toBe('Cluster is unrecoverable')
134
+ const objects = cnpgBlockedPhaseExplanation('Unable to create required cluster objects', ' ')
135
+ expect(objects.body).toBe('The operator keeps retrying, but this does not clear until its cause is fixed.')
136
+ })
137
+ })
138
+
118
139
  describe('classifyCNPGClusterPhase', () => {
119
140
  it('buckets every phase constant shipped by CNPG 1.27', () => {
120
141
  for (const p of CNPG_CLUSTER_PHASES_HEALTHY) expect(classifyCNPGClusterPhase(p)).toBe('healthy')
@@ -365,13 +386,17 @@ describe('getCNPGClusterInstancesReportedState', () => {
365
386
  // ============================================================================
366
387
 
367
388
  describe('getCNPGPoolerStatus', () => {
389
+ it('uses the requested count during scale-down without claiming readiness', () => {
390
+ expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 3 } })).toMatchObject({ text: '2 instances requested', level: 'neutral' })
391
+ expect(getCNPGPoolerStatus({ spec: { instances: 1 }, status: { instances: 1 } })).toMatchObject({ text: '1 instance requested', level: 'neutral' })
392
+ })
368
393
  it('does not claim readiness from status.instances, which counts scheduled pods', () => {
369
394
  // Verified live: a Pooler whose 2 PgBouncer pods are both Pending (0/1
370
395
  // ready) still reports status.instances=2. Calling that "Ready" renders a
371
396
  // broken Pooler green.
372
397
  const s = getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 2 } })
373
- expect(s.level).toBe('healthy')
374
- expect(s.text).toBe('Scheduled')
398
+ expect(s.level).toBe('neutral')
399
+ expect(s.text).toBe('2 instances requested')
375
400
  expect(s.text).not.toBe('Ready')
376
401
  })
377
402
 
@@ -595,7 +620,7 @@ describe('status column filter reads the badge, not the raw phase', () => {
595
620
  )).toBe('Suspended')
596
621
  expect(getCellFilterValue(
597
622
  { apiVersion: 'postgresql.cnpg.io/v1', spec: { instances: 2 }, status: { instances: 2 } }, 'status', 'poolers',
598
- )).toBe('Scheduled')
623
+ )).toBe('2 instances requested')
599
624
  })
600
625
 
601
626
  it('leaves a foreign CRD sharing the plural on the generic path', () => {
@@ -670,7 +695,7 @@ describe('an absent count is unknown, never zero', () => {
670
695
  expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 0 } }))
671
696
  .toMatchObject({ text: 'Not Scheduled', level: 'unhealthy' })
672
697
  expect(getCNPGPoolerStatus({ spec: { instances: 2 }, status: { instances: 2 } }))
673
- .toMatchObject({ text: 'Scheduled', level: 'healthy' })
698
+ .toMatchObject({ text: '2 instances requested', level: 'neutral' })
674
699
  })
675
700
  })
676
701
 
@@ -696,9 +721,9 @@ describe('paused Pooler', () => {
696
721
 
697
722
  it('only an explicit true pauses', () => {
698
723
  expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: {} }, status: { instances: 2 } }))
699
- .toMatchObject({ text: 'Scheduled', level: 'healthy' })
724
+ .toMatchObject({ text: '2 instances requested', level: 'neutral' })
700
725
  expect(getCNPGPoolerStatus({ spec: { instances: 2, pgbouncer: { paused: false } }, status: { instances: 2 } }))
701
- .toMatchObject({ text: 'Scheduled', level: 'healthy' })
726
+ .toMatchObject({ text: '2 instances requested', level: 'neutral' })
702
727
  expect(isCNPGPoolerPaused({ spec: { pgbouncer: { paused: true } } })).toBe(true)
703
728
  expect(isCNPGPoolerPaused({ spec: {} })).toBe(false)
704
729
  })
@@ -752,9 +777,9 @@ describe('CNPG scheduled backup lateness', () => {
752
777
  expect(badge.text).toBe('Overdue')
753
778
  })
754
779
 
755
- it('stays healthy inside the operator reconcile window', () => {
780
+ it('stays neutral inside the operator reconcile window', () => {
756
781
  const badge = getCNPGScheduledBackupStatus(sb(false, at(-1 * MIN), at(-24 * 60 * MIN)))
757
- expect(badge.level).toBe('healthy')
782
+ expect(badge.level).toBe('neutral')
758
783
  })
759
784
 
760
785
  it('keeps suspended as a deliberate state, not a missed backup', () => {
@@ -768,7 +793,7 @@ describe('CNPG scheduled backup lateness', () => {
768
793
  // The two read from one test. Before this they disagreed: the badge said
769
794
  // Active while this field said overdue on the same row.
770
795
  const justPastDue = sb(false, at(-1 * MIN), at(-24 * 60 * MIN))
771
- expect(getCNPGScheduledBackupStatus(justPastDue).level).toBe('healthy')
796
+ expect(getCNPGScheduledBackupStatus(justPastDue).level).toBe('neutral')
772
797
  expect(getCNPGScheduledBackupNextSchedule(justPastDue)).toBe('due now')
773
798
 
774
799
  const longPastDue = sb(false, at(-200 * 24 * 60 * MIN), at(-200 * 24 * 60 * MIN))
@@ -783,7 +808,7 @@ describe('CNPG scheduled backup lateness', () => {
783
808
 
784
809
  it('says nothing when the operator published no next-run time', () => {
785
810
  expect(getCNPGScheduledBackupNextSchedule(sb(false))).toBe('-')
786
- expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('healthy')
811
+ expect(getCNPGScheduledBackupStatus(sb(false, undefined, at(-24 * 60 * MIN))).level).toBe('neutral')
787
812
  })
788
813
  })
789
814
 
@@ -2,6 +2,9 @@
2
2
 
3
3
  import type { StatusBadge } from './resource-utils'
4
4
  import { healthColors, formatAge, formatDuration } from './resource-utils'
5
+ import { cnpgBackupDeclaration, cnpgBarmanPlugin } from '../../utils/cnpg-backup'
6
+ export { CNPG_BARMAN_PLUGIN_NAME } from '../../utils/cnpg-backup'
7
+
5
8
  import { parseGoTimeString } from '../../utils/parse-go-time'
6
9
 
7
10
  // ============================================================================
@@ -10,6 +13,11 @@ import { parseGoTimeString } from '../../utils/parse-go-time'
10
13
 
11
14
  export const CNPG_GROUP = 'postgresql.cnpg.io'
12
15
 
16
+ export function getCNPGPostgresMajor(cluster: any): number | undefined {
17
+ const major = cluster?.status?.pgDataImageInfo?.majorVersion ?? cluster?.spec?.imageCatalogRef?.major
18
+ return typeof major === 'number' && Number.isInteger(major) && major > 0 ? major : undefined
19
+ }
20
+
13
21
  /**
14
22
  * Exact API-group match for a resource's `apiVersion` ("group/version").
15
23
  *
@@ -56,7 +64,11 @@ export const CNPG_CLUSTER_PHASES_TRANSIENT = [
56
64
  /** Losing the primary — degraded now, self-resolving only if a replica can take over. */
57
65
  export const CNPG_CLUSTER_PHASES_FAILING = ['Failing over'] as const
58
66
 
59
- /** Reconciliation has stopped. These need a human; none of them self-heal. */
67
+ /**
68
+ * Reconciliation is blocked. The operator keeps retrying every one of these;
69
+ * only the plugin phases can clear without anyone changing something (a plugin
70
+ * Pod that comes back). See cnpgBlockedPhaseExplanation.
71
+ */
60
72
  export const CNPG_CLUSTER_PHASES_TERMINAL = [
61
73
  'Cluster is unrecoverable and needs manual intervention',
62
74
  // Not in 1.27 or 1.28; added upstream after 1.28 (PhaseDefinitionInvalid).
@@ -135,6 +147,34 @@ export function getCNPGClusterDisplayState(phase: string): string {
135
147
 
136
148
  export type CNPGPhaseBucket = 'healthy' | 'transient' | 'failing' | 'terminal' | 'attention' | 'unknown'
137
149
 
150
+ const CNPG_PLUGIN_BLOCKED_PHASES = new Set<string>([
151
+ 'Cluster cannot proceed to reconciliation due to an unknown plugin being required',
152
+ 'Cluster cannot proceed to reconciliation due to an error while interacting with plugins',
153
+ ])
154
+
155
+ /**
156
+ * What a blocked phase means for whoever is on call, with the operator's own
157
+ * `status.phaseReason` when it set one. The operator requeues the plugin
158
+ * phases every 10–15 s, so they clear once the plugin loads and answers;
159
+ * the others keep failing until their cause is fixed; "unrecoverable" is the
160
+ * one upstream itself says needs manual intervention.
161
+ */
162
+ export function cnpgBlockedPhaseExplanation(phase: string, phaseReason?: string): { title: string; message: string; body: string } {
163
+ const said = typeof phaseReason === 'string' ? phaseReason.trim() : ''
164
+ const reason = said ? ` The operator reports: ${/[.!?]$/.test(said) ? said : `${said}.`}` : ''
165
+ const out = (title: string, body: string) => ({ title, body: `${body}${reason}`, message: `${phase}. ${body}${reason}` })
166
+ if (phase === 'Cluster is unrecoverable and needs manual intervention') {
167
+ return out('Cluster is unrecoverable', 'The operator cannot bring it back by itself.')
168
+ }
169
+ if (CNPG_PLUGIN_BLOCKED_PHASES.has(phase)) {
170
+ return out(
171
+ 'Reconciliation is blocked by a plugin',
172
+ 'The operator retries every few seconds and continues once the plugin is loaded and answering, so a plugin Pod that is restarting clears on its own; check the operator and plugin logs if it does not.',
173
+ )
174
+ }
175
+ return out('Reconciliation is blocked', 'The operator keeps retrying, but this does not clear until its cause is fixed.')
176
+ }
177
+
138
178
  export function classifyCNPGClusterPhase(phase: string): CNPGPhaseBucket {
139
179
  if (!phase) return 'unknown'
140
180
  if ((CNPG_CLUSTER_PHASES_HEALTHY as readonly string[]).includes(phase)) return 'healthy'
@@ -414,7 +454,6 @@ export function getCNPGClusterUpdateStrategy(resource: any): string {
414
454
  return resource.spec?.primaryUpdateStrategy || 'unsupervised'
415
455
  }
416
456
 
417
- export const CNPG_BARMAN_PLUGIN_NAME = 'barman-cloud.cloudnative-pg.io'
418
457
 
419
458
  /** The API group ObjectStore is served under. `objectstores` is a generic
420
459
  * plural several operators ship, so every link to one has to be qualified —
@@ -438,20 +477,10 @@ export interface CNPGBarmanPlugin {
438
477
  * status.lastSuccessfulBackup / firstRecoverabilityPoint entirely.
439
478
  */
440
479
  export function getCNPGClusterBarmanPlugin(resource: any): CNPGBarmanPlugin | null {
441
- const plugins = resource.spec?.plugins
442
- if (!Array.isArray(plugins)) return null
443
- const plugin = plugins.find(
444
- (p: any) => p?.name === CNPG_BARMAN_PLUGIN_NAME && p?.enabled !== false
445
- )
480
+ const plugin = cnpgBarmanPlugin(cnpgBackupDeclaration(resource))
446
481
  if (!plugin) return null
447
- return {
448
- name: plugin.name,
449
- barmanObjectName: plugin.parameters?.barmanObjectName,
450
- // ObjectStore keys the recovery window by the plugin's serverName when set,
451
- // otherwise by the cluster name.
452
- serverName: plugin.parameters?.serverName || resource.metadata?.name,
453
- isWALArchiver: plugin.isWALArchiver === true,
454
- }
482
+ const { name, barmanObjectName, serverName, isWALArchiver } = plugin
483
+ return { name, barmanObjectName, serverName, isWALArchiver }
455
484
  }
456
485
 
457
486
  export function getCNPGClusterBackupConfig(resource: any): {
@@ -518,7 +547,7 @@ export function getCNPGDeclarativeMessage(resource: any): string | undefined {
518
547
  export function getCNPGReclaimPolicy(resource: any): { value: string; destructive: boolean } {
519
548
  const spec = resource?.spec ?? {}
520
549
  const raw =
521
- spec.databaseReclaimPolicy ?? spec.publicationReclaimPolicy ?? spec.subscriptionReclaimPolicy ?? 'retain'
550
+ spec.databaseReclaimPolicy ?? spec.publicationReclaimPolicy ?? spec.subscriptionReclaimPolicy ?? spec.databaseRoleReclaimPolicy ?? 'retain'
522
551
  return { value: raw, destructive: String(raw).toLowerCase() === 'delete' }
523
552
  }
524
553
 
@@ -933,15 +962,15 @@ export function getCNPGScheduledBackupStatus(resource: any): StatusBadge {
933
962
 
934
963
  // If we have a last schedule time, it's active
935
964
  if (resource.status?.lastScheduleTime) {
936
- return { text: 'Active', color: healthColors.healthy, level: 'healthy' }
965
+ return { text: 'Enabled', color: healthColors.neutral, level: 'neutral' }
937
966
  }
938
967
 
939
968
  // Check if immediate flag is set and no schedule has run yet
940
969
  if (resource.spec?.immediate) {
941
- return { text: 'Immediate', color: healthColors.healthy, level: 'healthy' }
970
+ return { text: 'Immediate run requested', color: healthColors.neutral, level: 'neutral' }
942
971
  }
943
972
 
944
- return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
973
+ return { text: 'Enabled · not run yet', color: healthColors.neutral, level: 'neutral' }
945
974
  }
946
975
 
947
976
  export function getCNPGScheduledBackupCluster(resource: any): string {
@@ -1030,8 +1059,10 @@ export function getCNPGPoolerStatus(resource: any): StatusBadge {
1030
1059
  return { text: 'Paused', color: healthColors.degraded, level: 'degraded' }
1031
1060
  }
1032
1061
 
1062
+ // status.instances counts scheduled Pods only; readiness lives on the
1063
+ // Pooler's Deployment, so this badge must not read as healthy.
1033
1064
  if (desired > 0 && scheduled >= desired) {
1034
- return { text: 'Scheduled', color: healthColors.healthy, level: 'healthy' }
1065
+ return { text: `${desired} instance${desired === 1 ? '' : 's'} requested`, color: healthColors.neutral, level: 'neutral' }
1035
1066
  }
1036
1067
 
1037
1068
  return { text: 'Unknown', color: healthColors.unknown, level: 'unknown' }
@@ -0,0 +1,44 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import { getContourSecretReference, getHTTPProxyTLSSecret } from './resource-utils-contour'
4
+
5
+ const proxy = (secretName?: string) => ({
6
+ metadata: { namespace: 'shop' },
7
+ spec: { virtualhost: { fqdn: 'shop.example.com', tls: secretName === undefined ? {} : { secretName } } },
8
+ })
9
+
10
+ describe('getHTTPProxyTLSSecret', () => {
11
+ it('resolves a bare name in the proxy namespace', () => {
12
+ expect(getHTTPProxyTLSSecret(proxy('cert'))).toEqual({ namespace: 'shop', name: 'cert' })
13
+ })
14
+ it('resolves a delegated namespace/name', () => {
15
+ expect(getHTTPProxyTLSSecret(proxy('certs/wildcard'))).toEqual({ namespace: 'certs', name: 'wildcard' })
16
+ })
17
+ it('defaults an empty namespace to the proxy namespace', () => {
18
+ expect(getHTTPProxyTLSSecret(proxy('/cert'))).toEqual({ namespace: 'shop', name: 'cert' })
19
+ })
20
+ it('rejects names that cannot identify a Kubernetes Secret', () => {
21
+ expect(getHTTPProxyTLSSecret(proxy('certs/a/b'))).toBeNull()
22
+ })
23
+ it('returns null without a secret', () => {
24
+ expect(getHTTPProxyTLSSecret(proxy())).toBeNull()
25
+ })
26
+ })
27
+
28
+ interface ReferenceCase {
29
+ case: string
30
+ raw: string
31
+ defaultNamespace: string
32
+ valid: boolean
33
+ namespace?: string
34
+ name?: string
35
+ }
36
+ const referenceCases: ReferenceCase[] = JSON.parse(readFileSync(new URL('../../../../../pkg/configrefs/testdata/contour_secret_refs.json', import.meta.url), 'utf8'))
37
+
38
+ describe('shared Go/TS Contour reference contract', () => {
39
+ it.each(referenceCases)('$case', (tc) => {
40
+ const expected = tc.valid ? { namespace: tc.namespace, name: tc.name } : undefined
41
+ expect(getContourSecretReference(tc.raw, tc.defaultNamespace)).toEqual(expected)
42
+ expect(getHTTPProxyTLSSecret({ ...proxy(tc.raw), metadata: { namespace: tc.defaultNamespace } })).toEqual(expected ?? null)
43
+ })
44
+ })
@@ -1,3 +1,5 @@
1
+ import { validateRFC1123Label, validateRFC1123Subdomain } from '../../utils/validators'
2
+
1
3
  // Contour HTTPProxy CRD utility functions for resource list cells and detail renderers
2
4
 
3
5
  export function getHTTPProxyFQDN(resource: any): string {
@@ -32,3 +34,17 @@ export function getHTTPProxyIncludeCount(resource: any): number {
32
34
  export function hasHTTPProxyTLS(resource: any): boolean {
33
35
  return !!resource.spec?.virtualhost?.tls
34
36
  }
37
+
38
+ export function getHTTPProxyTLSSecret(resource: any): { namespace: string; name: string } | null {
39
+ const raw: string | undefined = resource.spec?.virtualhost?.tls?.secretName
40
+ return raw ? getContourSecretReference(raw, resource.metadata?.namespace ?? '') ?? null : null
41
+ }
42
+
43
+ /** Contour's delegated TLS secret form is namespace/name. */
44
+ export function getContourSecretReference(raw: string, defaultNamespace: string): { name: string; namespace: string } | undefined {
45
+ const parts = raw.split('/')
46
+ const namespace = parts.length === 1 ? defaultNamespace : parts[0] || defaultNamespace
47
+ const name = parts.length === 1 ? parts[0] : parts[1]
48
+ if (parts.length > 2 || !validateRFC1123Label(namespace).valid || !validateRFC1123Subdomain(name).valid) return undefined
49
+ return { name, namespace }
50
+ }
@@ -8,13 +8,17 @@ import { pluralize } from '../../utils/pluralize'
8
8
  // SHARED HELPERS
9
9
  // ============================================================================
10
10
 
11
- function parseIstioHost(host: string): { name: string; namespace: string } {
12
- // Istio hosts can be: "reviews", "reviews.default", "reviews.default.svc.cluster.local"
11
+ // FQDNs require the registry domain supplied by the producer. Undotted
12
+ // names use Istio's documented namespace-relative expansion.
13
+ export function resolveIstioServiceHost(host: string, defaultNamespace: string, serviceDomain = ''): { name: string; namespace: string } | undefined {
14
+ const label = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/
13
15
  const parts = host.split('.')
14
- return {
15
- name: parts[0] || host,
16
- namespace: parts.length >= 2 ? parts[1] : '',
16
+ if (parts.length === 1 && host.length <= 63 && label.test(host)) return { name: host, namespace: defaultNamespace }
17
+ if (serviceDomain && host.endsWith(`.svc.${serviceDomain}`)) {
18
+ const service = host.slice(0, -(`.svc.${serviceDomain}`.length)).split('.')
19
+ if (service.length === 2 && service.every(part => part.length <= 63 && label.test(part))) return { name: service[0], namespace: service[1] }
17
20
  }
21
+ return undefined
18
22
  }
19
23
 
20
24
  // ============================================================================
@@ -103,16 +107,16 @@ export function getVirtualServiceTlsRoutes(resource: any): any[] {
103
107
  return resource.spec?.tls || []
104
108
  }
105
109
 
106
- export function getVirtualServiceDestinations(resource: any): Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> {
110
+ export function getVirtualServiceDestinations(resource: any, serviceDomain = ''): Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> {
107
111
  const destinations: Array<{ host: string; namespace: string; port?: number; subset?: string; weight?: number }> = []
108
112
  const httpRoutes = resource.spec?.http || []
109
113
  for (const route of httpRoutes) {
110
114
  for (const dest of (route.route || [])) {
111
115
  if (dest.destination?.host) {
112
- const parsed = parseIstioHost(dest.destination.host)
116
+ const parsed = resolveIstioServiceHost(dest.destination.host, resource.metadata?.namespace || '', serviceDomain)
113
117
  destinations.push({
114
118
  host: dest.destination.host,
115
- namespace: parsed.namespace || resource.metadata?.namespace || '',
119
+ namespace: parsed?.namespace || '',
116
120
  port: dest.destination.port?.number,
117
121
  subset: dest.destination.subset,
118
122
  weight: dest.weight,
@@ -1,3 +1,21 @@
1
+ import { apiVersionToGroup, kindToPluralWithGroup } from '../../utils/navigation'
2
+ import type { ResourceRef } from '../../types/core'
3
+
4
+ /** KReference records apiVersion or an explicit nonempty group. */
5
+ export function getKnativeReference(reference: any, defaultNamespace: string): ResourceRef | undefined {
6
+ const version = reference?.apiVersion
7
+ let group = reference?.group
8
+ if (typeof version === 'string' && version) {
9
+ const parts = version.split('/')
10
+ if (parts.length > 2 || parts.some(part => !part)) return undefined
11
+ const observed = apiVersionToGroup(version)
12
+ if (group && group !== observed) return undefined
13
+ group = observed
14
+ } else if (typeof group !== 'string' || !group) return undefined
15
+ if (typeof reference?.kind !== 'string' || !reference.kind || typeof reference?.name !== 'string' || !reference.name) return undefined
16
+ return { group, kind: kindToPluralWithGroup(reference.kind, group), name: reference.name, namespace: reference.namespace || defaultNamespace }
17
+ }
18
+
1
19
  // KNative CRD utility functions
2
20
 
3
21
  import type { StatusBadge } from './resource-utils'
@@ -86,12 +86,57 @@ export interface PodProblem {
86
86
  * misses. Presentation-only; the backend `scheduling` issue source does the
87
87
  * structured decomposition + node-label resolution (e.g. naming arm64).
88
88
  */
89
- export function summarizeSchedulerMessage(message?: string): string {
89
+ export function summarizeSchedulerMessage(message?: string, options: { plain?: boolean } = {}): string {
90
90
  if (!message) return ''
91
91
  let m = message.split('. preemption:')[0].split(' preemption:')[0].trim()
92
- const colon = m.indexOf(':')
93
- if (colon >= 0) m = m.slice(colon + 1).trim()
94
- return m.replace(/\.\s*$/, '').trim()
92
+ if (options.plain) {
93
+ m = m.replace(/^(?:Pod cannot be scheduled:\s*)?(?:Unschedulable:\s*)?(?:0\/\d+ nodes are available:\s*)?/i, '')
94
+ // Schedulers with dynamic resource allocation append this after the node reasons.
95
+ m = m.replace(/no new claims to deallocate,?/i, '').trim()
96
+ } else {
97
+ const colon = m.indexOf(':')
98
+ if (colon >= 0) m = m.slice(colon + 1).trim()
99
+ }
100
+ m = m.replace(/\.\s*$/, '').trim()
101
+ if (!options.plain) return m
102
+ const total = /0\/(\d+) nodes (?:are )?available/.exec(message)?.[1]
103
+ const causes: [RegExp, string][] = [
104
+ [/^(\d+) (?:Too many pods|(?:node\(s\) )?insufficient pods)(?: \(0\/\d+ nodes available\))?$/i, 'have reached their Pod limit'],
105
+ [/^(\d+) (?:node\(s\) )?Insufficient cpu(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough CPU'],
106
+ [/^(\d+) (?:node\(s\) )?Insufficient memory(?: \(0\/\d+ nodes available\))?$/i, 'do not have enough memory'],
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
+ [/^(\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'],
110
+ ]
111
+ return splitSchedulerClauses(m).filter((part) => part.trim()).map((part) => {
112
+ const clause = part.trim()
113
+ for (const [pattern, cause] of causes) {
114
+ const count = pattern.exec(clause)?.[1]
115
+ if (count) return `${count === '1' ? '1 node' : count === total && count === '2' ? 'both nodes' : count === total ? 'all nodes' : `${count} nodes`} ${count === '1' ? cause.replace(/^have /, 'has ').replace(/^do /, 'does ') : cause}`
116
+ }
117
+ if (/^(?:pod has )?unbound (?:immediate )?PersistentVolumeClaims?(?:\s*\(0\/\d+ nodes available\))?$/i.test(clause)) return 'a required volume claim is not bound'
118
+ return clause
119
+ }).join('; ')
120
+ }
121
+
122
+ // Splits on commas and semicolons, except where the next bracket ahead closes:
123
+ // scheduler taints and resolved node labels can contain comma-separated values.
124
+ // One pass from the end keeps long messages linear.
125
+ function splitSchedulerClauses(m: string): string[] {
126
+ const parts: string[] = []
127
+ let end = m.length
128
+ let insideBrackets = false
129
+ for (let i = m.length - 1; i >= 0; i--) {
130
+ const c = m[i]
131
+ if (c === ']' || c === '}') insideBrackets = true
132
+ else if (c === '[' || c === '{') insideBrackets = false
133
+ else if ((c === ',' || c === ';') && !insideBrackets) {
134
+ parts.push(m.slice(i + 1, end))
135
+ end = i
136
+ }
137
+ }
138
+ parts.push(m.slice(0, end))
139
+ return parts.reverse()
95
140
  }
96
141
 
97
142
  /** Tailwind classes for severity dot indicators (used in tooltips and alert banners) */
@@ -1854,7 +1899,7 @@ export function getGatewayClassDescription(gc: any): string {
1854
1899
  }
1855
1900
 
1856
1901
  // ============================================================================
1857
- // GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute)
1902
+ // GATEWAY API ROUTE UTILITIES (shared by HTTPRoute, GRPCRoute, TCPRoute, TLSRoute, UDPRoute)
1858
1903
  // ============================================================================
1859
1904
 
1860
1905
  const GATEWAY_API_GROUP = 'gateway.networking.k8s.io'
@@ -2264,6 +2309,37 @@ export function parseColumnFilterExcludes(filtersParam: string | null): Record<s
2264
2309
  return excludes
2265
2310
  }
2266
2311
 
2312
+ /** The Resources list's own filter params in the URL (the kind is in the path, `apiGroup` beside it). */
2313
+ export const RESOURCE_LIST_FILTER_PARAMS = ['search', 'regex', 'filters', 'problems', 'labels', 'ownerKind', 'ownerName', 'showInactive'] as const
2314
+
2315
+ /** The per-kind filters in a query string: what a kind switch resets or restores. */
2316
+ export function kindFiltersFromSearch(search: string | undefined) {
2317
+ const params = new URLSearchParams(search ?? '')
2318
+ const filtersParam = params.get('filters')
2319
+ return {
2320
+ columnFilters: parseColumnFilters(filtersParam),
2321
+ columnFilterExcludes: parseColumnFilterExcludes(filtersParam),
2322
+ problemFilters: params.get('problems')?.split(',').filter(Boolean) || [],
2323
+ }
2324
+ }
2325
+
2326
+ /** The `filters` param value. Drops an exclude flag on a column with no active values. */
2327
+ export function columnFiltersParam(colFilters: Record<string, string[]>, colExcludes: Record<string, boolean>): string {
2328
+ const activeExcludes = Object.fromEntries(
2329
+ Object.entries(colExcludes).filter(([k, on]) => on && (colFilters[k]?.length ?? 0) > 0)
2330
+ )
2331
+ return serializeColumnFilters(colFilters, activeExcludes)
2332
+ }
2333
+
2334
+ /** Inverse of kindFiltersFromSearch: the `filters` + `problems` query, '' when none. */
2335
+ export function kindFiltersToSearch(f: ReturnType<typeof kindFiltersFromSearch>): string {
2336
+ const params = new URLSearchParams()
2337
+ const filters = columnFiltersParam(f.columnFilters, f.columnFilterExcludes)
2338
+ if (filters) params.set('filters', filters)
2339
+ if (f.problemFilters.length > 0) params.set('problems', f.problemFilters.join(','))
2340
+ return params.toString()
2341
+ }
2342
+
2267
2343
  // Split a single "col[:operator]:values" pair into its decoded key, values, and
2268
2344
  // whether the explicit exclude operator was present. The first literal colon
2269
2345
  // delimits key from the rest (keys are encoded, so their own colons don't
@@ -2342,7 +2418,7 @@ export function getCellFilterValue(resource: any, column: string, kind: string):
2342
2418
  if (kindLower === 'hpas' || kindLower === 'horizontalpodautoscalers') return getHPAStatus(resource).text
2343
2419
  if (kindLower === 'gateways') return getGatewayStatus(resource).text
2344
2420
  if (kindLower === 'gatewayclasses') return getGatewayClassStatus(resource).text
2345
- if (['httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes'].includes(kindLower)) return getRouteStatus(resource).text
2421
+ if (['httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'udproutes'].includes(kindLower)) return getRouteStatus(resource).text
2346
2422
  if (kindLower === 'sealedsecrets') return getSealedSecretStatus(resource).text
2347
2423
  if (kindLower === 'poddisruptionbudgets') return getPDBStatus(resource).text
2348
2424
  if (kindLower === 'applications') return getArgoApplicationStatus(resource).text
@@ -27,7 +27,7 @@ function columnSetBody(key: string): string {
27
27
  // in the sortable-key list, so neither affordance ever renders on it. w-20
28
28
  // (80px) leaves 47px.
29
29
  describe('Rules column width', () => {
30
- const kinds = ['kyvernopolicies', 'clusterpolicies', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes']
30
+ const kinds = ['kyvernopolicies', 'clusterpolicies', 'httproutes', 'grpcroutes', 'tcproutes', 'tlsroutes', 'udproutes']
31
31
 
32
32
  it.each(kinds)('%s Rules column is at least w-20', (kind) => {
33
33
  const rules = columnSetBody(kind).match(/\{ key: 'rules'.*\}/)
@@ -28,3 +28,33 @@ describe('summarizeSchedulerMessage', () => {
28
28
  expect(summarizeSchedulerMessage(undefined)).toBe('')
29
29
  })
30
30
  })
31
+
32
+ it.each([
33
+ ['0/2 nodes are available: 2 Too many pods.', 'both nodes have reached their Pod limit'],
34
+ ['Unschedulable: 0/2 nodes are available: 2 Too many pods.', 'both nodes have reached their Pod limit'],
35
+ ['2 node(s) insufficient pods (0/2 nodes available)', 'both nodes have reached their Pod limit'],
36
+ ['0/1 nodes are available: 1 Insufficient cpu.', '1 node does not have enough CPU'],
37
+ ['0/3 nodes are available: 3 Insufficient cpu.', 'all nodes do not have enough CPU'],
38
+ ['0/2 nodes are available: 2 Insufficient memory, 2 Too many pods.', 'both nodes do not have enough memory; both nodes have reached their Pod limit'],
39
+ ["0/2 nodes are available: 2 node(s) didn’t match Pod’s node affinity/selector.", 'both nodes do not match the Pod’s node affinity or selector'],
40
+ ['1 node(s) had untolerated taint {dedicated: db}.', '1 node has taints the Pod does not tolerate'],
41
+ ['0/2 nodes are available: 1 node(s) had untolerated taint {dedicated: db}.', '1 node has taints the Pod does not tolerate'],
42
+ ['0/2 nodes are available: pod has unbound immediate PersistentVolumeClaims.', 'a required volume claim is not bound'],
43
+ ['1 node(s) untolerated taint dedicated=db, 1 node(s) insufficient cpu (0/2 nodes available)', '1 node has taints the Pod does not tolerate; 1 node does not have enough CPU'],
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
+ ['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
+ ['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
+ ['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
+ ['custom scheduler says 2 Insufficient cpu, 1 Insufficient memory', 'custom scheduler says 2 Insufficient cpu; 1 node does not have enough memory'],
49
+ ['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]'],
50
+ ["0/14 nodes are available: 6 node(s) had untolerated taint(s), 8 node(s) didn't match Pod's node affinity/selector. no new claims to deallocate, preemption: 0/14 nodes are available: 14 Preemption is not helpful for scheduling.", '6 nodes have taints the Pod does not tolerate; 8 nodes do not match the Pod’s node affinity or selector'],
51
+ ])('offers a plain summary additively for %s', (raw, expected) => {
52
+ expect(summarizeSchedulerMessage(raw, { plain: true })).toBe(expected)
53
+ })
54
+
55
+ it('summarizes a very long, separator-heavy message in linear time', () => {
56
+ const message = '0/2 nodes are available: ' + '1 node(s) custom reason, '.repeat(10_000) + '2 Insufficient cpu.'
57
+ const summary = summarizeSchedulerMessage(message, { plain: true })
58
+ expect(summary.split('; ')).toHaveLength(10_001)
59
+ expect(summary.endsWith('both nodes do not have enough CPU')).toBe(true)
60
+ })