@skyhook-io/k8s-ui 1.10.5 → 1.12.0

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 (80) hide show
  1. package/package.json +1 -1
  2. package/src/components/gitops/GitOpsStatusBadge.tsx +40 -0
  3. package/src/components/gitops/GitOpsTableView.tsx +22 -2
  4. package/src/components/gitops/ReconcilingIndicator.test.tsx +31 -0
  5. package/src/components/gitops/detail-helpers.ts +4 -1
  6. package/src/components/issues/IssuesView.tsx +9 -0
  7. package/src/components/issues/diagnostic.ts +3 -0
  8. package/src/components/issues/issues.test.ts +11 -0
  9. package/src/components/issues/severity.ts +3 -0
  10. package/src/components/issues/types.ts +3 -0
  11. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  12. package/src/components/resources/ResourcesView.tsx +261 -30
  13. package/src/components/resources/index.ts +2 -0
  14. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  15. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  16. package/src/components/resources/renderers/AlertRenderer.tsx +4 -1
  17. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  18. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +133 -9
  19. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +19 -4
  20. package/src/components/resources/renderers/CompositeRenderer.test.tsx +81 -0
  21. package/src/components/resources/renderers/CompositeRenderer.tsx +67 -7
  22. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  23. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  24. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +4 -1
  25. package/src/components/resources/renderers/GitRepositoryRenderer.tsx +4 -1
  26. package/src/components/resources/renderers/HelmRepositoryRenderer.tsx +4 -1
  27. package/src/components/resources/renderers/KustomizationRenderer.tsx +4 -1
  28. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  29. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  30. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  31. package/src/components/resources/renderers/OCIRepositoryRenderer.tsx +4 -1
  32. package/src/components/resources/renderers/PodRenderer.tsx +13 -0
  33. package/src/components/resources/renderers/PolicySection.test.tsx +102 -0
  34. package/src/components/resources/renderers/PolicySection.tsx +170 -0
  35. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  36. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  37. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  38. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  39. package/src/components/resources/renderers/WorkloadRenderer.tsx +15 -1
  40. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  41. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  42. package/src/components/resources/renderers/index.ts +5 -0
  43. package/src/components/resources/renderers/kyverno-cells.tsx +9 -0
  44. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  45. package/src/components/resources/renderers/policyreport-subject.test.tsx +27 -0
  46. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  47. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  48. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  49. package/src/components/resources/resource-utils-cnpg.test.ts +590 -1
  50. package/src/components/resources/resource-utils-cnpg.ts +433 -56
  51. package/src/components/resources/resource-utils-crossplane.test.ts +68 -0
  52. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  53. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  54. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  55. package/src/components/resources/resource-utils-velero.ts +214 -22
  56. package/src/components/resources/resource-utils.ts +70 -3
  57. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  58. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  59. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  60. package/src/components/topology/K8sResourceNode.tsx +6 -0
  61. package/src/components/trace/ReachabilityGraph.tsx +20 -6
  62. package/src/components/trace/ReachabilityView.tsx +253 -52
  63. package/src/components/trace/problemRows.test.ts +107 -0
  64. package/src/components/trace/reachGraphModel.test.ts +16 -0
  65. package/src/components/trace/reachInspector.test.ts +233 -10
  66. package/src/components/trace/reachInspector.ts +166 -36
  67. package/src/components/trace/reachMarks.test.ts +32 -1
  68. package/src/components/trace/reachMarks.ts +44 -0
  69. package/src/components/trace/reachOrigins.ts +12 -0
  70. package/src/components/trace/types.ts +25 -0
  71. package/src/components/ui/Badge.tsx +36 -0
  72. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  73. package/src/theme/components.css +26 -0
  74. package/src/types/gitops-flux-status.test.ts +163 -0
  75. package/src/types/gitops.ts +80 -24
  76. package/src/types/index.ts +1 -0
  77. package/src/types/policy.ts +42 -0
  78. package/src/utils/api-resources.ts +2 -0
  79. package/src/utils/resource-icons.test.ts +33 -0
  80. package/src/utils/resource-icons.ts +65 -2
@@ -153,9 +153,14 @@ import { ResourceClaimCell, ResourceClaimTemplateCell, DeviceClassCell, Resource
153
153
  import { NvidiaClusterPolicyCell, NvidiaDriverCell } from './renderers/nvidia-cells'
154
154
  import { ServiceMonitorCell, PrometheusRuleCell, PodMonitorCell } from './renderers/prometheus-cells'
155
155
  import { PolicyReportCell, ClusterPolicyReportCell, KyvernoPolicyCell, ClusterPolicyCell } from './renderers/kyverno-cells'
156
+ import { KyvernoModernPolicyCell, KyvernoPolicyExceptionCell, KyvernoCleanupPolicyCell } from './renderers/kyverno-modern-cells'
157
+ import { KYVERNO_MODERN_PLURALS, isModernKyvernoPolicy } from './resource-utils-kyverno-modern'
158
+ import { isAnyKyvernoPolicyException } from './resource-utils-kyverno-exceptions'
156
159
  import { ExternalSecretCell, ClusterExternalSecretCell, SecretStoreCell, ClusterSecretStoreCell } from './renderers/eso-cells'
157
- import { BackupCell, RestoreCell, ScheduleCell, BackupStorageLocationCell } from './renderers/velero-cells'
160
+ import { BackupCell, RestoreCell, ScheduleCell, BackupStorageLocationCell, VolumeSnapshotLocationCell, BackupRepositoryCell } from './renderers/velero-cells'
161
+ import { isVeleroResource } from './resource-utils-velero'
158
162
  import { CNPGClusterCell, CNPGBackupCell, CNPGScheduledBackupCell, CNPGPoolerCell } from './renderers/cnpg-cells'
163
+ import { isApiGroup, CNPG_GROUP } from './resource-utils-cnpg'
159
164
  import { ManagedResourceCell, CompositeResourceCell, CrossplaneProviderCell, CrossplaneProviderConfigCell, CompositionCell, XRDCell } from './renderers/crossplane-cells'
160
165
  import { isManagedResource, isComposite } from './resource-utils-crossplane'
161
166
  import { VirtualServiceCell, DestinationRuleCell, IstioGatewayCell, ServiceEntryCell, PeerAuthenticationCell, AuthorizationPolicyCell } from './renderers/istio-cells'
@@ -702,20 +707,22 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
702
707
  policyreports: [
703
708
  { key: 'name', label: 'Name' },
704
709
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
705
- { key: 'status', label: 'Status', width: 'w-24' },
710
+ { key: 'scope', label: 'Subject', width: 'w-56' },
711
+ { key: 'status', label: 'Status', width: 'w-32' },
706
712
  { key: 'pass', label: 'Pass', width: 'w-16' },
707
713
  { key: 'fail', label: 'Fail', width: 'w-16' },
708
- { key: 'warn', label: 'Warn', width: 'w-16' },
714
+ { key: 'warn', label: 'Warn', width: 'w-20' },
709
715
  { key: 'error', label: 'Err', width: 'w-16' },
710
716
  { key: 'skip', label: 'Skip', width: 'w-16' },
711
717
  { key: 'age', label: 'Age', width: 'w-24' },
712
718
  ],
713
719
  clusterpolicyreports: [
714
720
  { key: 'name', label: 'Name' },
715
- { key: 'status', label: 'Status', width: 'w-24' },
721
+ { key: 'scope', label: 'Subject', width: 'w-56' },
722
+ { key: 'status', label: 'Status', width: 'w-32' },
716
723
  { key: 'pass', label: 'Pass', width: 'w-16' },
717
724
  { key: 'fail', label: 'Fail', width: 'w-16' },
718
- { key: 'warn', label: 'Warn', width: 'w-16' },
725
+ { key: 'warn', label: 'Warn', width: 'w-20' },
719
726
  { key: 'error', label: 'Err', width: 'w-16' },
720
727
  { key: 'skip', label: 'Skip', width: 'w-16' },
721
728
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -735,6 +742,119 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
735
742
  { key: 'rules', label: 'Rules', width: 'w-16' },
736
743
  { key: 'age', label: 'Age', width: 'w-24' },
737
744
  ],
745
+ // Kyverno modern CEL family (policies.kyverno.io). "Enforcement" is the
746
+ // effective posture, not spec.validationActions verbatim — a policy that
747
+ // declares Deny with admission evaluation disabled blocks nothing.
748
+ validatingpolicies: [
749
+ { key: 'name', label: 'Name' },
750
+ { key: 'status', label: 'Enforcement', width: 'w-44', tooltip: 'Effective enforcement posture, accounting for whether admission evaluation is enabled' },
751
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40', tooltip: 'Resources matched by spec.matchConstraints' },
752
+ { key: 'rules', label: 'Rules', width: 'w-20', tooltip: 'CEL validation expressions' },
753
+ { key: 'age', label: 'Age', width: 'w-24' },
754
+ ],
755
+ namespacedvalidatingpolicies: [
756
+ { key: 'name', label: 'Name' },
757
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
758
+ { key: 'status', label: 'Enforcement', width: 'w-44', tooltip: 'Effective enforcement posture, accounting for whether admission evaluation is enabled' },
759
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
760
+ { key: 'rules', label: 'Rules', width: 'w-20' },
761
+ { key: 'age', label: 'Age', width: 'w-24' },
762
+ ],
763
+ imagevalidatingpolicies: [
764
+ { key: 'name', label: 'Name' },
765
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
766
+ { key: 'images', label: 'Images', width: 'min-w-40', tooltip: 'Image references this policy verifies' },
767
+ { key: 'attestors', label: 'Attestors', width: 'w-20', tooltip: 'Trusted signing authorities' },
768
+ { key: 'age', label: 'Age', width: 'w-24' },
769
+ ],
770
+ namespacedimagevalidatingpolicies: [
771
+ { key: 'name', label: 'Name' },
772
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
773
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
774
+ { key: 'images', label: 'Images', width: 'min-w-40' },
775
+ { key: 'attestors', label: 'Attestors', width: 'w-20' },
776
+ { key: 'age', label: 'Age', width: 'w-24' },
777
+ ],
778
+ mutatingpolicies: [
779
+ { key: 'name', label: 'Name' },
780
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
781
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
782
+ { key: 'rules', label: 'Mutations', width: 'w-20' },
783
+ { key: 'age', label: 'Age', width: 'w-24' },
784
+ ],
785
+ namespacedmutatingpolicies: [
786
+ { key: 'name', label: 'Name' },
787
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
788
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
789
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
790
+ { key: 'rules', label: 'Mutations', width: 'w-20' },
791
+ { key: 'age', label: 'Age', width: 'w-24' },
792
+ ],
793
+ generatingpolicies: [
794
+ { key: 'name', label: 'Name' },
795
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
796
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
797
+ { key: 'rules', label: 'Generates', width: 'w-20' },
798
+ { key: 'age', label: 'Age', width: 'w-24' },
799
+ ],
800
+ namespacedgeneratingpolicies: [
801
+ { key: 'name', label: 'Name' },
802
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
803
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
804
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
805
+ { key: 'rules', label: 'Generates', width: 'w-20' },
806
+ { key: 'age', label: 'Age', width: 'w-24' },
807
+ ],
808
+ // The Deleting kinds are the only modern family whose headline column is the
809
+ // schedule rather than the enforcement posture, so they carry their own
810
+ // health signal. It is Last Run, NOT readiness: Kyverno 1.18.2 declares a
811
+ // READY printer column on these CRDs but never populates
812
+ // status.conditionStatus.ready, so `kubectl get deletingpolicies` prints it
813
+ // blank and a Ready column here would read "unknown" on every row forever.
814
+ // It also has nothing to catch — an uncompilable policy is rejected at
815
+ // admission and never exists. What does go wrong is a policy that exists and
816
+ // silently never fires, which lastExecutionTime shows.
817
+ //
818
+ // The other eight kinds deliberately have no readiness column either: their
819
+ // status cell already returns "Not Ready" IN PLACE OF the posture (see
820
+ // getModernKyvernoPolicyStatus), so a second one would be redundant.
821
+ deletingpolicies: [
822
+ { key: 'name', label: 'Name' },
823
+ { key: 'lastRun', label: 'Last Run', width: 'w-28', tooltip: 'When the schedule last fired. A scheduled policy that has never run is the failure worth catching — Kyverno rejects uncompilable policies at admission, so a broken one never exists to flag.' },
824
+ { key: 'schedule', label: 'Schedule', width: 'w-32', tooltip: 'Cron schedule on which matched resources are deleted' },
825
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
826
+ { key: 'rules', label: 'Conditions', width: 'w-28', tooltip: 'CEL conditions narrowing what is deleted; none means every matched resource' },
827
+ { key: 'age', label: 'Age', width: 'w-24' },
828
+ ],
829
+ namespaceddeletingpolicies: [
830
+ { key: 'name', label: 'Name' },
831
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
832
+ { key: 'lastRun', label: 'Last Run', width: 'w-28', tooltip: 'When the schedule last fired. A scheduled policy that has never run is the failure worth catching — Kyverno rejects uncompilable policies at admission, so a broken one never exists to flag.' },
833
+ { key: 'schedule', label: 'Schedule', width: 'w-32' },
834
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
835
+ { key: 'rules', label: 'Conditions', width: 'w-28' },
836
+ { key: 'age', label: 'Age', width: 'w-24' },
837
+ ],
838
+ policyexceptions: [
839
+ { key: 'name', label: 'Name' },
840
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
841
+ { key: 'status', label: 'Exempts', width: 'w-28', tooltip: 'How many policies this exception bypasses' },
842
+ { key: 'policies', label: 'Policies', width: 'min-w-40' },
843
+ { key: 'age', label: 'Age', width: 'w-24' },
844
+ ],
845
+ cleanuppolicies: [
846
+ { key: 'name', label: 'Name' },
847
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
848
+ { key: 'schedule', label: 'Schedule', width: 'w-32' },
849
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
850
+ { key: 'age', label: 'Age', width: 'w-24' },
851
+ ],
852
+ clustercleanuppolicies: [
853
+ { key: 'name', label: 'Name' },
854
+ { key: 'schedule', label: 'Schedule', width: 'w-32' },
855
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
856
+ { key: 'age', label: 'Age', width: 'w-24' },
857
+ ],
738
858
  grpcroutes: [
739
859
  { key: 'name', label: 'Name' },
740
860
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -1100,39 +1220,64 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1100
1220
  backups: [
1101
1221
  { key: 'name', label: 'Name' },
1102
1222
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1103
- { key: 'status', label: 'Status', width: 'w-28' },
1223
+ { key: 'status', label: 'Status', width: 'w-36' },
1104
1224
  { key: 'storageLocation', label: 'Storage', width: 'w-36' },
1105
1225
  { key: 'namespaces', label: 'Scope', width: 'w-24', tooltip: 'Included namespaces (* = all)' },
1106
1226
  { key: 'duration', label: 'Duration', width: 'w-24' },
1107
1227
  { key: 'expiry', label: 'Expires', width: 'w-24' },
1108
- { key: 'errors', label: 'Errors', width: 'w-20' },
1228
+ { key: 'errors', label: 'Errors', width: 'w-28' },
1109
1229
  { key: 'age', label: 'Age', width: 'w-24' },
1110
1230
  ],
1111
- restores: [
1231
+ // `restores` and `schedules` are keyed group-qualified (see
1232
+ // GROUP_QUALIFIED_COLUMN_KEYS): rancher/backup-restore-operator ships
1233
+ // restores.resources.cattle.io and several operators ship their own
1234
+ // `schedules` kind. Only velero.io resolves to these column sets;
1235
+ // everything else falls through to the generic columns.
1236
+ velerorestores: [
1112
1237
  { key: 'name', label: 'Name' },
1113
1238
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1114
- { key: 'status', label: 'Status', width: 'w-28' },
1239
+ { key: 'status', label: 'Status', width: 'w-36' },
1115
1240
  { key: 'backupName', label: 'Backup', width: 'w-40' },
1116
1241
  { key: 'duration', label: 'Duration', width: 'w-24' },
1117
- { key: 'errors', label: 'Errors', width: 'w-20' },
1242
+ { key: 'errors', label: 'Errors', width: 'w-28' },
1118
1243
  { key: 'age', label: 'Age', width: 'w-24' },
1119
1244
  ],
1120
- schedules: [
1245
+ veleroschedules: [
1121
1246
  { key: 'name', label: 'Name' },
1122
1247
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1123
- { key: 'status', label: 'Status', width: 'w-24' },
1124
- { key: 'schedule', label: 'Schedule', width: 'w-32' },
1125
- { key: 'lastBackup', label: 'Last Backup', width: 'w-28' },
1126
- { key: 'paused', label: 'Paused', width: 'w-16' },
1248
+ { key: 'status', label: 'Status', width: 'w-36' },
1249
+ { key: 'schedule', label: 'Schedule', width: 'w-40' },
1250
+ { key: 'lastBackup', label: 'Last Backup', width: 'w-32' },
1251
+ { key: 'age', label: 'Age', width: 'w-24' },
1252
+ ],
1253
+ // No status column on purpose: the VSL controller never populates
1254
+ // status.phase, so a badge would read "Unknown" on every row forever.
1255
+ volumesnapshotlocations: [
1256
+ { key: 'name', label: 'Name' },
1257
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1258
+ { key: 'provider', label: 'Provider', width: 'w-32' },
1259
+ { key: 'config', label: 'Config' },
1260
+ { key: 'age', label: 'Age', width: 'w-24' },
1261
+ ],
1262
+ // repositoryType is kopia|restic, and it is what `kubectl get
1263
+ // backuprepositories` shows. It earns a column because restic is being retired
1264
+ // (no new backups since v1.17, restore dropped in v1.19), which makes it a
1265
+ // migration liability worth spotting by scanning rather than by opening each
1266
+ // repository one at a time.
1267
+ backuprepositories: [
1268
+ { key: 'name', label: 'Name' },
1269
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1270
+ { key: 'status', label: 'Status', width: 'w-36' },
1271
+ { key: 'repositoryType', label: 'Type', width: 'w-24' },
1127
1272
  { key: 'age', label: 'Age', width: 'w-24' },
1128
1273
  ],
1129
1274
  backupstoragelocations: [
1130
1275
  { key: 'name', label: 'Name' },
1131
1276
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1132
- { key: 'status', label: 'Status', width: 'w-24' },
1277
+ { key: 'status', label: 'Status', width: 'w-36' },
1133
1278
  { key: 'provider', label: 'Provider', width: 'w-24' },
1134
1279
  { key: 'bucket', label: 'Bucket', width: 'w-40' },
1135
- { key: 'default', label: 'Default', width: 'w-16' },
1280
+ { key: 'default', label: 'Default', width: 'w-24' },
1136
1281
  { key: 'lastValidation', label: 'Validated', width: 'w-28' },
1137
1282
  { key: 'age', label: 'Age', width: 'w-24' },
1138
1283
  ],
@@ -1142,11 +1287,34 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1142
1287
  cnpgclusters: [
1143
1288
  { key: 'name', label: 'Name' },
1144
1289
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1145
- { key: 'status', label: 'Status', width: 'w-28' },
1290
+ // w-28 leaves the "Status" label 0.23px short once the sort AND filter
1291
+ // affordances are both present, which renders "STAT…" — a sub-pixel miss
1292
+ // costs two characters because the ellipsis needs its own room. The filter
1293
+ // icon appears as soon as a column holds more than one distinct value, so
1294
+ // this is latent on any status column, not specific to the current data.
1295
+ // w-44 fits "WAL Archiving Failing" (120.2px against a 127px label budget).
1296
+ // CNPG's own phases are mapped to short display states — see
1297
+ // getCNPGClusterDisplayState; a 315px sentence fits no column at all.
1298
+ { key: 'status', label: 'Status', width: 'w-44' },
1146
1299
  { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1147
1300
  { key: 'primary', label: 'Primary', width: 'w-36' },
1148
1301
  { key: 'image', label: 'Image', width: 'w-28' },
1149
- { key: 'storage', label: 'Storage', width: 'w-28' },
1302
+ // No Storage column: it rendered spec.storage.size, the configured REQUEST.
1303
+ // The useful number is actual usage (kubectl cnpg status shows "Size: 158M")
1304
+ // and that isn't in the CR. A plausible-but-wrong-meaning number is worse
1305
+ // than an absent one — the reader can't tell which meaning they're getting.
1306
+ { key: 'age', label: 'Age', width: 'w-24' },
1307
+ ],
1308
+ cnpgbackups: [
1309
+ { key: 'name', label: 'Name' },
1310
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1311
+ { key: 'status', label: 'Status', width: 'w-44' },
1312
+ { key: 'cluster', label: 'Cluster', width: 'w-36' },
1313
+ // `barmanObjectStore` is the longest method and the field that decides how
1314
+ // the rest of the row reads; at w-36 it was permanently ellipsised.
1315
+ { key: 'method', label: 'Method', width: 'w-40' },
1316
+ { key: 'started', label: 'Started', width: 'w-24' },
1317
+ { key: 'duration', label: 'Duration', width: 'w-24' },
1150
1318
  { key: 'age', label: 'Age', width: 'w-24' },
1151
1319
  ],
1152
1320
  scheduledbackups: [
@@ -1162,11 +1330,19 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1162
1330
  poolers: [
1163
1331
  { key: 'name', label: 'Name' },
1164
1332
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1165
- { key: 'status', label: 'Status', width: 'w-24' },
1333
+ // "Not Scheduled" (83.5px) is kept rather than shortened to "Unscheduled":
1334
+ // ScheduledBackup also renders a literal "Scheduled" badge in this same
1335
+ // column, so a standalone adjective would read as "has no cron" — a claim
1336
+ // about a field Poolers don't have. w-36 buys the words.
1337
+ { key: 'status', label: 'Status', width: 'w-36' },
1166
1338
  { key: 'cluster', label: 'Cluster', width: 'w-36' },
1167
- { key: 'type', label: 'Type', width: 'w-16' },
1339
+ // Sized for the HEADER, not the value: `rw`/`ro` need 27px, but "Type" plus
1340
+ // the sort affordance needs more than w-16 leaves, and the label rendered
1341
+ // as "T…".
1342
+ { key: 'type', label: 'Type', width: 'w-24' },
1168
1343
  { key: 'poolMode', label: 'Pool Mode', width: 'w-32' },
1169
- { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1344
+ // status.instances counts pods trying to be scheduled, not ready ones.
1345
+ { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Scheduled/Total' },
1170
1346
  { key: 'age', label: 'Age', width: 'w-24' },
1171
1347
  ],
1172
1348
  // ============================================================================
@@ -1694,6 +1870,9 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1694
1870
  // Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
1695
1871
  const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
1696
1872
  clusters: { 'postgresql.cnpg.io': 'cnpgclusters', 'cluster.x-k8s.io': 'capiclusters' },
1873
+ // Velero owns the unqualified `backups` column set; CNPG Backups carry a
1874
+ // completely different shape (cluster + method, no storage location/expiry).
1875
+ backups: { 'postgresql.cnpg.io': 'cnpgbackups' },
1697
1876
  clusterpolicies: { 'nvidia.com': 'nvidiaclusterpolicies' },
1698
1877
  services: { 'serving.knative.dev': 'knativeservices' },
1699
1878
  configurations: { 'serving.knative.dev': 'knativeconfigurations' },
@@ -1701,6 +1880,8 @@ const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
1701
1880
  routes: { 'serving.knative.dev': 'knativeroutes' },
1702
1881
  ingresses: { 'networking.internal.knative.dev': 'knativeingresses' },
1703
1882
  certificates: { 'networking.internal.knative.dev': 'knativecertificates' },
1883
+ restores: { 'velero.io': 'velerorestores' },
1884
+ schedules: { 'velero.io': 'veleroschedules' },
1704
1885
  }
1705
1886
 
1706
1887
  // Normalize a kind name to its plural API form used in KNOWN_COLUMNS keys.
@@ -5455,6 +5636,29 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5455
5636
 
5456
5637
  // Kind-specific columns (normalize CRD singular names like 'ScaledObject' → 'scaledobjects')
5457
5638
  const kindLower = normalizeKindToPlural(kind, group)
5639
+
5640
+ // Kyverno cells are group-gated ahead of the switch so a non-matching CR
5641
+ // falls through to the switch's default (GenericCell) instead of being
5642
+ // described in Kyverno's vocabulary. These plurals are generic enough that
5643
+ // another vendor could ship them, and `policyexceptions` is served by BOTH
5644
+ // Kyverno API families with different spec shapes. The stakes are higher
5645
+ // here than in the drawer: an absent `validationActions` legitimately means
5646
+ // Deny for a real Kyverno policy, so an ungated foreign CR would render a
5647
+ // red "Deny" badge purely because it lacks a field it never had.
5648
+ if (KYVERNO_MODERN_PLURALS.has(kindLower) && isModernKyvernoPolicy(resource)) {
5649
+ return <KyvernoModernPolicyCell resource={resource} column={column} />
5650
+ }
5651
+ if (kindLower === 'policyexceptions' && isAnyKyvernoPolicyException(resource)) {
5652
+ return <KyvernoPolicyExceptionCell resource={resource} column={column} />
5653
+ }
5654
+ if (
5655
+ (kindLower === 'cleanuppolicies' || kindLower === 'clustercleanuppolicies') &&
5656
+ typeof resource?.apiVersion === 'string' &&
5657
+ resource.apiVersion.startsWith('kyverno.io/')
5658
+ ) {
5659
+ return <KyvernoCleanupPolicyCell resource={resource} column={column} />
5660
+ }
5661
+
5458
5662
  switch (kindLower) {
5459
5663
  case 'pods':
5460
5664
  return <PodCell resource={resource} column={column} />
@@ -5644,26 +5848,53 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5644
5848
  case 'clustersecretstores':
5645
5849
  return <ClusterSecretStoreCell resource={resource} column={column} />
5646
5850
  // Velero
5851
+ case 'cnpgbackups':
5852
+ return <CNPGBackupCell resource={resource} column={column} />
5647
5853
  case 'backups':
5648
- // Disambiguate CNPG vs Velero backups by apiVersion
5649
- if (resource.apiVersion?.includes('cnpg.io')) {
5854
+ // Reached only when the group is unknown to normalizeKindToPlural. Both
5855
+ // engines are matched positively so a third `backups` CRD renders generic
5856
+ // rather than inheriting whichever branch happened to be the fallback.
5857
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5650
5858
  return <CNPGBackupCell resource={resource} column={column} />
5651
5859
  }
5652
- return <BackupCell resource={resource} column={column} />
5653
- case 'restores':
5860
+ if (isApiGroup(resource.apiVersion, 'velero.io')) {
5861
+ return <BackupCell resource={resource} column={column} />
5862
+ }
5863
+ return <GenericCell resource={resource} column={column} />
5864
+ case 'velerorestores':
5654
5865
  return <RestoreCell resource={resource} column={column} />
5655
- case 'schedules':
5866
+ case 'veleroschedules':
5656
5867
  return <ScheduleCell resource={resource} column={column} />
5657
5868
  case 'backupstoragelocations':
5869
+ if (!isVeleroResource(resource)) return <GenericCell resource={resource} column={column} />
5658
5870
  return <BackupStorageLocationCell resource={resource} column={column} />
5871
+ case 'volumesnapshotlocations':
5872
+ if (!isVeleroResource(resource)) return <GenericCell resource={resource} column={column} />
5873
+ return <VolumeSnapshotLocationCell resource={resource} column={column} />
5874
+ case 'backuprepositories':
5875
+ if (!isVeleroResource(resource)) return <GenericCell resource={resource} column={column} />
5876
+ return <BackupRepositoryCell resource={resource} column={column} />
5659
5877
  // CloudNativePG
5660
5878
  case 'cnpgclusters':
5661
- case 'clusters':
5662
5879
  return <CNPGClusterCell resource={resource} column={column} />
5880
+ case 'clusters':
5881
+ // Positive guard: `clusters` is one of the most collided CRD plurals
5882
+ // (CNPG, CAPI, KubeBlocks, Redis/Valkey operators). Anything else gets
5883
+ // the generic cell instead of a fabricated Postgres status.
5884
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5885
+ return <CNPGClusterCell resource={resource} column={column} />
5886
+ }
5887
+ return <GenericCell resource={resource} column={column} />
5663
5888
  case 'scheduledbackups':
5664
- return <CNPGScheduledBackupCell resource={resource} column={column} />
5889
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5890
+ return <CNPGScheduledBackupCell resource={resource} column={column} />
5891
+ }
5892
+ return <GenericCell resource={resource} column={column} />
5665
5893
  case 'poolers':
5666
- return <CNPGPoolerCell resource={resource} column={column} />
5894
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5895
+ return <CNPGPoolerCell resource={resource} column={column} />
5896
+ }
5897
+ return <GenericCell resource={resource} column={column} />
5667
5898
  // Istio Service Mesh
5668
5899
  case 'virtualservices':
5669
5900
  return <VirtualServiceCell resource={resource} column={column} />
@@ -11,6 +11,8 @@ export * from './resource-utils-karpenter'
11
11
  export * from './resource-utils-keda'
12
12
  export * from './resource-utils-knative'
13
13
  export * from './resource-utils-kyverno'
14
+ export * from './resource-utils-kyverno-modern'
15
+ export * from './resource-utils-kyverno-exceptions'
14
16
  export * from './resource-utils-prometheus'
15
17
  export * from './resource-utils-trivy'
16
18
  export * from './resource-utils-traefik'
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ KYVERNO_MODERN_PLURALS,
4
+ getKyvernoLastExecutionTime,
5
+ getKyvernoPolicyReady,
6
+ getModernKyvernoPolicyStatus,
7
+ isModernKyvernoPolicy,
8
+ } from './resource-utils-kyverno-modern'
9
+ import { isAnyKyvernoPolicyException } from './resource-utils-kyverno-exceptions'
10
+
11
+ // Bugbot #4 (MEDIUM): the ResourcesView cell dispatch matched Kyverno plurals
12
+ // without checking the API group, so a foreign CRD sharing a plural picked up
13
+ // Kyverno columns and badges while its drawer correctly stayed generic.
14
+ //
15
+ // The stakes are higher in the table than in the drawer: an absent
16
+ // `validationActions` legitimately means Deny for a real Kyverno policy, so an
17
+ // ungated foreign CR renders a red "Deny" purely for lacking a field it never
18
+ // had. These pin the predicates the cell dispatch now gates on.
19
+ describe('kyverno cell group gating', () => {
20
+ const foreignValidatingPolicy = {
21
+ apiVersion: 'policy.example.com/v1',
22
+ kind: 'ValidatingPolicy',
23
+ metadata: { name: 'not-kyverno' },
24
+ spec: {},
25
+ }
26
+
27
+ it('rejects a foreign CR that merely shares the plural', () => {
28
+ expect(KYVERNO_MODERN_PLURALS.has('validatingpolicies')).toBe(true)
29
+ expect(isModernKyvernoPolicy(foreignValidatingPolicy)).toBe(false)
30
+ })
31
+
32
+ // Proof the gate is load-bearing rather than defensive: ungated, this exact
33
+ // foreign object renders as Deny.
34
+ it('would read as Deny without the gate', () => {
35
+ expect(getModernKyvernoPolicyStatus(foreignValidatingPolicy).text).toBe('Deny')
36
+ expect(getModernKyvernoPolicyStatus(foreignValidatingPolicy).level).toBe('unhealthy')
37
+ })
38
+
39
+ it('accepts a genuine modern Kyverno policy', () => {
40
+ const real = { apiVersion: 'policies.kyverno.io/v1', kind: 'ValidatingPolicy', spec: { validationActions: ['Audit'] } }
41
+ expect(isModernKyvernoPolicy(real)).toBe(true)
42
+ expect(getModernKyvernoPolicyStatus(real).text).toBe('Audit')
43
+ })
44
+
45
+ // PolicyException is served by BOTH Kyverno families, so the gate accepts
46
+ // either group while still rejecting a foreign one.
47
+ it('accepts PolicyException from both Kyverno groups, rejects foreign', () => {
48
+ expect(isAnyKyvernoPolicyException({ apiVersion: 'kyverno.io/v2' })).toBe(true)
49
+ expect(isAnyKyvernoPolicyException({ apiVersion: 'policies.kyverno.io/v1' })).toBe(true)
50
+ expect(isAnyKyvernoPolicyException({ apiVersion: 'exceptions.example.com/v1' })).toBe(false)
51
+ expect(isAnyKyvernoPolicyException({})).toBe(false)
52
+ })
53
+
54
+ // The legacy cleanup pair gates on the legacy group specifically.
55
+ it('gates CleanupPolicy on the legacy kyverno.io group', () => {
56
+ expect('kyverno.io/v2'.startsWith('kyverno.io/')).toBe(true)
57
+ expect('cleanup.example.com/v1'.startsWith('kyverno.io/')).toBe(false)
58
+ })
59
+ })
60
+
61
+ // The Deleting kinds carry a Last Run column rather than a readiness one.
62
+ // Verified on Kyverno 1.18.2: the CRD declares a READY printer column but the
63
+ // controller never populates status.conditionStatus.ready — even after the
64
+ // cron has fired, status is {conditionStatus:{message:""}, lastExecutionTime}.
65
+ // A Ready column would read "unknown" on every row forever. lastExecutionTime
66
+ // is populated, varies, and catches the failure that actually happens: a
67
+ // scheduled policy that silently never runs.
68
+ describe('deleting-policy last-run signal', () => {
69
+ const withLastRun = (t?: string) => ({
70
+ apiVersion: 'policies.kyverno.io/v1',
71
+ kind: 'DeletingPolicy',
72
+ spec: { schedule: '0 2 * * *' },
73
+ status: t ? { lastExecutionTime: t } : { conditionStatus: { message: '' } },
74
+ })
75
+
76
+ it('reads lastExecutionTime when the schedule has fired', () => {
77
+ expect(getKyvernoLastExecutionTime(withLastRun('2026-08-08T13:38:00Z'))).toBe('2026-08-08T13:38:00Z')
78
+ })
79
+
80
+ // Must stay empty rather than defaulting to anything — the cell renders
81
+ // "Never run" for this, which is the state worth catching.
82
+ it('returns empty when the policy has never run', () => {
83
+ expect(getKyvernoLastExecutionTime(withLastRun())).toBe('')
84
+ expect(getKyvernoLastExecutionTime({})).toBe('')
85
+ })
86
+
87
+ // The status shape Kyverno 1.18.2 actually writes after a real cron tick.
88
+ it('is empty for the real post-tick shape that omits ready', () => {
89
+ const real = { status: { conditionStatus: { message: '' }, lastExecutionTime: '2026-08-08T13:38:00Z' } }
90
+ expect(getKyvernoLastExecutionTime(real)).toBe('2026-08-08T13:38:00Z')
91
+ expect(getKyvernoPolicyReady(real)).toBeUndefined()
92
+ })
93
+ })