@skyhook-io/k8s-ui 1.10.5 → 1.11.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 (57) hide show
  1. package/package.json +1 -1
  2. package/src/components/issues/IssuesView.tsx +9 -0
  3. package/src/components/issues/diagnostic.ts +3 -0
  4. package/src/components/issues/issues.test.ts +11 -0
  5. package/src/components/issues/severity.ts +3 -0
  6. package/src/components/issues/types.ts +3 -0
  7. package/src/components/resources/ResourcesSidebar.tsx +1 -1
  8. package/src/components/resources/ResourcesView.tsx +259 -30
  9. package/src/components/resources/index.ts +2 -0
  10. package/src/components/resources/kyverno-cell-gating.test.ts +93 -0
  11. package/src/components/resources/kyverno-modern-posture.test.ts +290 -0
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +85 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +109 -9
  14. package/src/components/resources/renderers/CNPGPoolerRenderer.tsx +8 -4
  15. package/src/components/resources/renderers/EventRenderer.test.tsx +23 -0
  16. package/src/components/resources/renderers/EventRenderer.tsx +10 -18
  17. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +317 -0
  18. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +264 -0
  19. package/src/components/resources/renderers/KyvernoPolicyShared.tsx +212 -0
  20. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +2 -4
  21. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +32 -9
  22. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +30 -9
  23. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +12 -6
  24. package/src/components/resources/renderers/badge-no-handrolled.test.tsx +1 -1
  25. package/src/components/resources/renderers/cnpg-cells.tsx +37 -2
  26. package/src/components/resources/renderers/index.ts +4 -0
  27. package/src/components/resources/renderers/kyverno-modern-cells.tsx +152 -0
  28. package/src/components/resources/renderers/velero-cells.tsx +128 -9
  29. package/src/components/resources/renderers/velero-phase-recovery.test.tsx +79 -0
  30. package/src/components/resources/resource-utils-cnpg.golden.test.ts +112 -0
  31. package/src/components/resources/resource-utils-cnpg.test.ts +527 -1
  32. package/src/components/resources/resource-utils-cnpg.ts +373 -56
  33. package/src/components/resources/resource-utils-kyverno-exceptions.ts +182 -0
  34. package/src/components/resources/resource-utils-kyverno-modern.ts +588 -0
  35. package/src/components/resources/resource-utils-velero.test.ts +237 -0
  36. package/src/components/resources/resource-utils-velero.ts +214 -22
  37. package/src/components/resources/resource-utils.ts +70 -3
  38. package/src/components/shared/ResourceActionsBar.tsx +3 -1
  39. package/src/components/shared/ResourceRendererDispatch.test.tsx +119 -1
  40. package/src/components/shared/ResourceRendererDispatch.tsx +116 -22
  41. package/src/components/topology/K8sResourceNode.tsx +6 -0
  42. package/src/components/trace/ReachabilityGraph.tsx +20 -6
  43. package/src/components/trace/ReachabilityView.tsx +253 -52
  44. package/src/components/trace/problemRows.test.ts +107 -0
  45. package/src/components/trace/reachGraphModel.test.ts +16 -0
  46. package/src/components/trace/reachInspector.test.ts +233 -10
  47. package/src/components/trace/reachInspector.ts +166 -36
  48. package/src/components/trace/reachMarks.test.ts +32 -1
  49. package/src/components/trace/reachMarks.ts +44 -0
  50. package/src/components/trace/reachOrigins.ts +12 -0
  51. package/src/components/trace/types.ts +25 -0
  52. package/src/components/ui/Badge.tsx +36 -0
  53. package/src/components/ui/ForceDeleteConfirmDialog.tsx +12 -1
  54. package/src/theme/components.css +26 -0
  55. package/src/utils/api-resources.ts +2 -0
  56. package/src/utils/resource-icons.test.ts +33 -0
  57. package/src/utils/resource-icons.ts +65 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skyhook-io/k8s-ui",
3
- "version": "1.10.5",
3
+ "version": "1.11.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/skyhook-io/radar",
@@ -240,6 +240,13 @@ export function IssueRow({
240
240
  </Tooltip>
241
241
  ) : null}
242
242
  </>
243
+ ) : issue.onset_unknown ? (
244
+ <Tooltip content="Radar can confirm this issue is active, but current Kubernetes state does not reveal when it began." delay={200} wrapperClassName="shrink-0">
245
+ <span className="flex items-center gap-1 text-xs text-theme-text-tertiary">
246
+ <Clock className="h-3 w-3" aria-hidden />
247
+ Onset unknown
248
+ </span>
249
+ </Tooltip>
243
250
  ) : null}
244
251
  </div>
245
252
  );
@@ -451,6 +458,8 @@ function Diagnosis({ issue, source }: { issue: Issue; source?: IssueDiagnosisSou
451
458
  meta.push(timing.meta);
452
459
  } else if (issue.first_seen) {
453
460
  meta.push(`started ${formatRelativeAgeTime(issue.first_seen)}`);
461
+ } else if (issue.onset_unknown) {
462
+ meta.push('onset unknown');
454
463
  }
455
464
  if (issue.first_seen) {
456
465
  if (issue.last_seen && timing?.kind !== 'creation') meta.push(`last seen ${formatRelativeAgeTime(issue.last_seen)}`);
@@ -46,6 +46,8 @@ export function diagnosticFactLabel(type: string): string {
46
46
  return 'Stalled autoscalers';
47
47
  case 'secret_not_ready':
48
48
  return 'Dependent pods';
49
+ case 'admission_webhook_backend':
50
+ return 'Admission dependency';
49
51
  default:
50
52
  return type.replace(/_/g, ' ');
51
53
  }
@@ -59,6 +61,7 @@ export function incidentParentLabel(factType?: string, confidence?: string): str
59
61
  switch (factType) {
60
62
  case 'pvc_blast_radius':
61
63
  case 'secret_not_ready':
64
+ case 'admission_webhook_backend':
62
65
  return 'Caused by';
63
66
  case 'apiservice_hpa':
64
67
  return 'Likely cause';
@@ -126,6 +126,17 @@ describe('IssueRow', () => {
126
126
  expect(html).toContain('Control plane')
127
127
  expect(html).toContain(groupBadgeClass('control_plane'))
128
128
  })
129
+
130
+ it('shows an explicit unknown-onset state instead of inventing an age', () => {
131
+ const html = renderToString(createElement(IssueRow, {
132
+ issue: mk({ onset_unknown: true }),
133
+ open: false,
134
+ onToggle: () => undefined,
135
+ }))
136
+
137
+ expect(html).toContain('Onset unknown')
138
+ expect(html).not.toContain('0s')
139
+ })
129
140
  })
130
141
 
131
142
  describe('issueTiming', () => {
@@ -71,6 +71,7 @@ const CATEGORY_LABEL: Record<string, string> = {
71
71
  secret_sync_failed: 'Secret sync failed',
72
72
  service_no_endpoints: 'No endpoints',
73
73
  ingress_backend_missing: 'Ingress backend missing',
74
+ ingress_class_missing: 'Ingress class missing',
74
75
  load_balancer_pending: 'Load balancer pending',
75
76
  gateway_not_ready: 'Gateway not ready',
76
77
  gateway_route_invalid: 'Gateway route invalid',
@@ -82,6 +83,8 @@ const CATEGORY_LABEL: Record<string, string> = {
82
83
  pvc_resize_failed: 'PVC resize failed',
83
84
  volume_mount_failed: 'Volume mount failed',
84
85
  volume_access_mode_conflict: 'Volume access conflict',
86
+ backup_failed: 'Backup failed',
87
+ backup_target_unavailable: 'Backup target unavailable',
85
88
  job_failed: 'Job failed',
86
89
  cronjob_failed: 'CronJob failed',
87
90
  rollout_stalled: 'Rollout stalled',
@@ -198,6 +198,9 @@ export interface Issue {
198
198
  * Capacity / Demand view, so a generic scheduling failure never links. */
199
199
  capacity_relevant?: boolean;
200
200
  first_seen?: string;
201
+ /** Radar can confirm the issue is active, but current cluster evidence does
202
+ * not establish when the failing state began. */
203
+ onset_unknown?: boolean;
201
204
  last_seen?: string;
202
205
  /** Affected-resource fan-out, EXCLUDING the subject (the row header).
203
206
  * 0/omitted for a single-resource issue; e.g. 50 for one Deployment's
@@ -107,7 +107,7 @@ interface ResourceTypeButtonProps {
107
107
 
108
108
  const ResourceTypeButton = forwardRef<HTMLButtonElement, ResourceTypeButtonProps>(
109
109
  function ResourceTypeButton({ resource, count, isSelected, isHighlighted, isForbidden: forbidden, isPinned, onTogglePin, onClick }, ref) {
110
- const Icon = getResourceIcon(resource.kind)
110
+ const Icon = getResourceIcon(resource.kind, resource.group)
111
111
  return (
112
112
  <button
113
113
  ref={ref}
@@ -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,20 @@ 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: 'status', label: 'Status', width: 'w-32' },
706
711
  { key: 'pass', label: 'Pass', width: 'w-16' },
707
712
  { key: 'fail', label: 'Fail', width: 'w-16' },
708
- { key: 'warn', label: 'Warn', width: 'w-16' },
713
+ { key: 'warn', label: 'Warn', width: 'w-20' },
709
714
  { key: 'error', label: 'Err', width: 'w-16' },
710
715
  { key: 'skip', label: 'Skip', width: 'w-16' },
711
716
  { key: 'age', label: 'Age', width: 'w-24' },
712
717
  ],
713
718
  clusterpolicyreports: [
714
719
  { key: 'name', label: 'Name' },
715
- { key: 'status', label: 'Status', width: 'w-24' },
720
+ { key: 'status', label: 'Status', width: 'w-32' },
716
721
  { key: 'pass', label: 'Pass', width: 'w-16' },
717
722
  { key: 'fail', label: 'Fail', width: 'w-16' },
718
- { key: 'warn', label: 'Warn', width: 'w-16' },
723
+ { key: 'warn', label: 'Warn', width: 'w-20' },
719
724
  { key: 'error', label: 'Err', width: 'w-16' },
720
725
  { key: 'skip', label: 'Skip', width: 'w-16' },
721
726
  { key: 'age', label: 'Age', width: 'w-24' },
@@ -735,6 +740,119 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
735
740
  { key: 'rules', label: 'Rules', width: 'w-16' },
736
741
  { key: 'age', label: 'Age', width: 'w-24' },
737
742
  ],
743
+ // Kyverno modern CEL family (policies.kyverno.io). "Enforcement" is the
744
+ // effective posture, not spec.validationActions verbatim — a policy that
745
+ // declares Deny with admission evaluation disabled blocks nothing.
746
+ validatingpolicies: [
747
+ { key: 'name', label: 'Name' },
748
+ { key: 'status', label: 'Enforcement', width: 'w-44', tooltip: 'Effective enforcement posture, accounting for whether admission evaluation is enabled' },
749
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40', tooltip: 'Resources matched by spec.matchConstraints' },
750
+ { key: 'rules', label: 'Rules', width: 'w-20', tooltip: 'CEL validation expressions' },
751
+ { key: 'age', label: 'Age', width: 'w-24' },
752
+ ],
753
+ namespacedvalidatingpolicies: [
754
+ { key: 'name', label: 'Name' },
755
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
756
+ { key: 'status', label: 'Enforcement', width: 'w-44', tooltip: 'Effective enforcement posture, accounting for whether admission evaluation is enabled' },
757
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
758
+ { key: 'rules', label: 'Rules', width: 'w-20' },
759
+ { key: 'age', label: 'Age', width: 'w-24' },
760
+ ],
761
+ imagevalidatingpolicies: [
762
+ { key: 'name', label: 'Name' },
763
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
764
+ { key: 'images', label: 'Images', width: 'min-w-40', tooltip: 'Image references this policy verifies' },
765
+ { key: 'attestors', label: 'Attestors', width: 'w-20', tooltip: 'Trusted signing authorities' },
766
+ { key: 'age', label: 'Age', width: 'w-24' },
767
+ ],
768
+ namespacedimagevalidatingpolicies: [
769
+ { key: 'name', label: 'Name' },
770
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
771
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
772
+ { key: 'images', label: 'Images', width: 'min-w-40' },
773
+ { key: 'attestors', label: 'Attestors', width: 'w-20' },
774
+ { key: 'age', label: 'Age', width: 'w-24' },
775
+ ],
776
+ mutatingpolicies: [
777
+ { key: 'name', label: 'Name' },
778
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
779
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
780
+ { key: 'rules', label: 'Mutations', width: 'w-20' },
781
+ { key: 'age', label: 'Age', width: 'w-24' },
782
+ ],
783
+ namespacedmutatingpolicies: [
784
+ { key: 'name', label: 'Name' },
785
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
786
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
787
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
788
+ { key: 'rules', label: 'Mutations', width: 'w-20' },
789
+ { key: 'age', label: 'Age', width: 'w-24' },
790
+ ],
791
+ generatingpolicies: [
792
+ { key: 'name', label: 'Name' },
793
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
794
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
795
+ { key: 'rules', label: 'Generates', width: 'w-20' },
796
+ { key: 'age', label: 'Age', width: 'w-24' },
797
+ ],
798
+ namespacedgeneratingpolicies: [
799
+ { key: 'name', label: 'Name' },
800
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
801
+ { key: 'status', label: 'Enforcement', width: 'w-44' },
802
+ { key: 'appliesTo', label: 'Applies To', width: 'min-w-40' },
803
+ { key: 'rules', label: 'Generates', width: 'w-20' },
804
+ { key: 'age', label: 'Age', width: 'w-24' },
805
+ ],
806
+ // The Deleting kinds are the only modern family whose headline column is the
807
+ // schedule rather than the enforcement posture, so they carry their own
808
+ // health signal. It is Last Run, NOT readiness: Kyverno 1.18.2 declares a
809
+ // READY printer column on these CRDs but never populates
810
+ // status.conditionStatus.ready, so `kubectl get deletingpolicies` prints it
811
+ // blank and a Ready column here would read "unknown" on every row forever.
812
+ // It also has nothing to catch — an uncompilable policy is rejected at
813
+ // admission and never exists. What does go wrong is a policy that exists and
814
+ // silently never fires, which lastExecutionTime shows.
815
+ //
816
+ // The other eight kinds deliberately have no readiness column either: their
817
+ // status cell already returns "Not Ready" IN PLACE OF the posture (see
818
+ // getModernKyvernoPolicyStatus), so a second one would be redundant.
819
+ deletingpolicies: [
820
+ { key: 'name', label: 'Name' },
821
+ { 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.' },
822
+ { key: 'schedule', label: 'Schedule', width: 'w-32', tooltip: 'Cron schedule on which matched resources are deleted' },
823
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
824
+ { key: 'rules', label: 'Conditions', width: 'w-28', tooltip: 'CEL conditions narrowing what is deleted; none means every matched resource' },
825
+ { key: 'age', label: 'Age', width: 'w-24' },
826
+ ],
827
+ namespaceddeletingpolicies: [
828
+ { key: 'name', label: 'Name' },
829
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
830
+ { 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.' },
831
+ { key: 'schedule', label: 'Schedule', width: 'w-32' },
832
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
833
+ { key: 'rules', label: 'Conditions', width: 'w-28' },
834
+ { key: 'age', label: 'Age', width: 'w-24' },
835
+ ],
836
+ policyexceptions: [
837
+ { key: 'name', label: 'Name' },
838
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
839
+ { key: 'status', label: 'Exempts', width: 'w-28', tooltip: 'How many policies this exception bypasses' },
840
+ { key: 'policies', label: 'Policies', width: 'min-w-40' },
841
+ { key: 'age', label: 'Age', width: 'w-24' },
842
+ ],
843
+ cleanuppolicies: [
844
+ { key: 'name', label: 'Name' },
845
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
846
+ { key: 'schedule', label: 'Schedule', width: 'w-32' },
847
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
848
+ { key: 'age', label: 'Age', width: 'w-24' },
849
+ ],
850
+ clustercleanuppolicies: [
851
+ { key: 'name', label: 'Name' },
852
+ { key: 'schedule', label: 'Schedule', width: 'w-32' },
853
+ { key: 'appliesTo', label: 'Deletes', width: 'min-w-40' },
854
+ { key: 'age', label: 'Age', width: 'w-24' },
855
+ ],
738
856
  grpcroutes: [
739
857
  { key: 'name', label: 'Name' },
740
858
  { key: 'namespace', label: 'Namespace', width: 'w-48' },
@@ -1100,39 +1218,64 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1100
1218
  backups: [
1101
1219
  { key: 'name', label: 'Name' },
1102
1220
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1103
- { key: 'status', label: 'Status', width: 'w-28' },
1221
+ { key: 'status', label: 'Status', width: 'w-36' },
1104
1222
  { key: 'storageLocation', label: 'Storage', width: 'w-36' },
1105
1223
  { key: 'namespaces', label: 'Scope', width: 'w-24', tooltip: 'Included namespaces (* = all)' },
1106
1224
  { key: 'duration', label: 'Duration', width: 'w-24' },
1107
1225
  { key: 'expiry', label: 'Expires', width: 'w-24' },
1108
- { key: 'errors', label: 'Errors', width: 'w-20' },
1226
+ { key: 'errors', label: 'Errors', width: 'w-28' },
1109
1227
  { key: 'age', label: 'Age', width: 'w-24' },
1110
1228
  ],
1111
- restores: [
1229
+ // `restores` and `schedules` are keyed group-qualified (see
1230
+ // GROUP_QUALIFIED_COLUMN_KEYS): rancher/backup-restore-operator ships
1231
+ // restores.resources.cattle.io and several operators ship their own
1232
+ // `schedules` kind. Only velero.io resolves to these column sets;
1233
+ // everything else falls through to the generic columns.
1234
+ velerorestores: [
1112
1235
  { key: 'name', label: 'Name' },
1113
1236
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1114
- { key: 'status', label: 'Status', width: 'w-28' },
1237
+ { key: 'status', label: 'Status', width: 'w-36' },
1115
1238
  { key: 'backupName', label: 'Backup', width: 'w-40' },
1116
1239
  { key: 'duration', label: 'Duration', width: 'w-24' },
1117
- { key: 'errors', label: 'Errors', width: 'w-20' },
1240
+ { key: 'errors', label: 'Errors', width: 'w-28' },
1118
1241
  { key: 'age', label: 'Age', width: 'w-24' },
1119
1242
  ],
1120
- schedules: [
1243
+ veleroschedules: [
1121
1244
  { key: 'name', label: 'Name' },
1122
1245
  { 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' },
1246
+ { key: 'status', label: 'Status', width: 'w-36' },
1247
+ { key: 'schedule', label: 'Schedule', width: 'w-40' },
1248
+ { key: 'lastBackup', label: 'Last Backup', width: 'w-32' },
1249
+ { key: 'age', label: 'Age', width: 'w-24' },
1250
+ ],
1251
+ // No status column on purpose: the VSL controller never populates
1252
+ // status.phase, so a badge would read "Unknown" on every row forever.
1253
+ volumesnapshotlocations: [
1254
+ { key: 'name', label: 'Name' },
1255
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1256
+ { key: 'provider', label: 'Provider', width: 'w-32' },
1257
+ { key: 'config', label: 'Config' },
1258
+ { key: 'age', label: 'Age', width: 'w-24' },
1259
+ ],
1260
+ // repositoryType is kopia|restic, and it is what `kubectl get
1261
+ // backuprepositories` shows. It earns a column because restic is being retired
1262
+ // (no new backups since v1.17, restore dropped in v1.19), which makes it a
1263
+ // migration liability worth spotting by scanning rather than by opening each
1264
+ // repository one at a time.
1265
+ backuprepositories: [
1266
+ { key: 'name', label: 'Name' },
1267
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1268
+ { key: 'status', label: 'Status', width: 'w-36' },
1269
+ { key: 'repositoryType', label: 'Type', width: 'w-24' },
1127
1270
  { key: 'age', label: 'Age', width: 'w-24' },
1128
1271
  ],
1129
1272
  backupstoragelocations: [
1130
1273
  { key: 'name', label: 'Name' },
1131
1274
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1132
- { key: 'status', label: 'Status', width: 'w-24' },
1275
+ { key: 'status', label: 'Status', width: 'w-36' },
1133
1276
  { key: 'provider', label: 'Provider', width: 'w-24' },
1134
1277
  { key: 'bucket', label: 'Bucket', width: 'w-40' },
1135
- { key: 'default', label: 'Default', width: 'w-16' },
1278
+ { key: 'default', label: 'Default', width: 'w-24' },
1136
1279
  { key: 'lastValidation', label: 'Validated', width: 'w-28' },
1137
1280
  { key: 'age', label: 'Age', width: 'w-24' },
1138
1281
  ],
@@ -1142,11 +1285,34 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1142
1285
  cnpgclusters: [
1143
1286
  { key: 'name', label: 'Name' },
1144
1287
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1145
- { key: 'status', label: 'Status', width: 'w-28' },
1288
+ // w-28 leaves the "Status" label 0.23px short once the sort AND filter
1289
+ // affordances are both present, which renders "STAT…" — a sub-pixel miss
1290
+ // costs two characters because the ellipsis needs its own room. The filter
1291
+ // icon appears as soon as a column holds more than one distinct value, so
1292
+ // this is latent on any status column, not specific to the current data.
1293
+ // w-44 fits "WAL Archiving Failing" (120.2px against a 127px label budget).
1294
+ // CNPG's own phases are mapped to short display states — see
1295
+ // getCNPGClusterDisplayState; a 315px sentence fits no column at all.
1296
+ { key: 'status', label: 'Status', width: 'w-44' },
1146
1297
  { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1147
1298
  { key: 'primary', label: 'Primary', width: 'w-36' },
1148
1299
  { key: 'image', label: 'Image', width: 'w-28' },
1149
- { key: 'storage', label: 'Storage', width: 'w-28' },
1300
+ // No Storage column: it rendered spec.storage.size, the configured REQUEST.
1301
+ // The useful number is actual usage (kubectl cnpg status shows "Size: 158M")
1302
+ // and that isn't in the CR. A plausible-but-wrong-meaning number is worse
1303
+ // than an absent one — the reader can't tell which meaning they're getting.
1304
+ { key: 'age', label: 'Age', width: 'w-24' },
1305
+ ],
1306
+ cnpgbackups: [
1307
+ { key: 'name', label: 'Name' },
1308
+ { key: 'namespace', label: 'Namespace', width: 'w-36' },
1309
+ { key: 'status', label: 'Status', width: 'w-44' },
1310
+ { key: 'cluster', label: 'Cluster', width: 'w-36' },
1311
+ // `barmanObjectStore` is the longest method and the field that decides how
1312
+ // the rest of the row reads; at w-36 it was permanently ellipsised.
1313
+ { key: 'method', label: 'Method', width: 'w-40' },
1314
+ { key: 'started', label: 'Started', width: 'w-24' },
1315
+ { key: 'duration', label: 'Duration', width: 'w-24' },
1150
1316
  { key: 'age', label: 'Age', width: 'w-24' },
1151
1317
  ],
1152
1318
  scheduledbackups: [
@@ -1162,11 +1328,19 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1162
1328
  poolers: [
1163
1329
  { key: 'name', label: 'Name' },
1164
1330
  { key: 'namespace', label: 'Namespace', width: 'w-36' },
1165
- { key: 'status', label: 'Status', width: 'w-24' },
1331
+ // "Not Scheduled" (83.5px) is kept rather than shortened to "Unscheduled":
1332
+ // ScheduledBackup also renders a literal "Scheduled" badge in this same
1333
+ // column, so a standalone adjective would read as "has no cron" — a claim
1334
+ // about a field Poolers don't have. w-36 buys the words.
1335
+ { key: 'status', label: 'Status', width: 'w-36' },
1166
1336
  { key: 'cluster', label: 'Cluster', width: 'w-36' },
1167
- { key: 'type', label: 'Type', width: 'w-16' },
1337
+ // Sized for the HEADER, not the value: `rw`/`ro` need 27px, but "Type" plus
1338
+ // the sort affordance needs more than w-16 leaves, and the label rendered
1339
+ // as "T…".
1340
+ { key: 'type', label: 'Type', width: 'w-24' },
1168
1341
  { key: 'poolMode', label: 'Pool Mode', width: 'w-32' },
1169
- { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Ready/Total' },
1342
+ // status.instances counts pods trying to be scheduled, not ready ones.
1343
+ { key: 'instances', label: 'Instances', width: 'w-28', tooltip: 'Scheduled/Total' },
1170
1344
  { key: 'age', label: 'Age', width: 'w-24' },
1171
1345
  ],
1172
1346
  // ============================================================================
@@ -1694,6 +1868,9 @@ const KNOWN_COLUMNS: Record<string, Column[]> = {
1694
1868
  // Map (plural, group) → KNOWN_COLUMNS key for kinds that collide with core K8s
1695
1869
  const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
1696
1870
  clusters: { 'postgresql.cnpg.io': 'cnpgclusters', 'cluster.x-k8s.io': 'capiclusters' },
1871
+ // Velero owns the unqualified `backups` column set; CNPG Backups carry a
1872
+ // completely different shape (cluster + method, no storage location/expiry).
1873
+ backups: { 'postgresql.cnpg.io': 'cnpgbackups' },
1697
1874
  clusterpolicies: { 'nvidia.com': 'nvidiaclusterpolicies' },
1698
1875
  services: { 'serving.knative.dev': 'knativeservices' },
1699
1876
  configurations: { 'serving.knative.dev': 'knativeconfigurations' },
@@ -1701,6 +1878,8 @@ const GROUP_QUALIFIED_COLUMN_KEYS: Record<string, Record<string, string>> = {
1701
1878
  routes: { 'serving.knative.dev': 'knativeroutes' },
1702
1879
  ingresses: { 'networking.internal.knative.dev': 'knativeingresses' },
1703
1880
  certificates: { 'networking.internal.knative.dev': 'knativecertificates' },
1881
+ restores: { 'velero.io': 'velerorestores' },
1882
+ schedules: { 'velero.io': 'veleroschedules' },
1704
1883
  }
1705
1884
 
1706
1885
  // Normalize a kind name to its plural API form used in KNOWN_COLUMNS keys.
@@ -5455,6 +5634,29 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5455
5634
 
5456
5635
  // Kind-specific columns (normalize CRD singular names like 'ScaledObject' → 'scaledobjects')
5457
5636
  const kindLower = normalizeKindToPlural(kind, group)
5637
+
5638
+ // Kyverno cells are group-gated ahead of the switch so a non-matching CR
5639
+ // falls through to the switch's default (GenericCell) instead of being
5640
+ // described in Kyverno's vocabulary. These plurals are generic enough that
5641
+ // another vendor could ship them, and `policyexceptions` is served by BOTH
5642
+ // Kyverno API families with different spec shapes. The stakes are higher
5643
+ // here than in the drawer: an absent `validationActions` legitimately means
5644
+ // Deny for a real Kyverno policy, so an ungated foreign CR would render a
5645
+ // red "Deny" badge purely because it lacks a field it never had.
5646
+ if (KYVERNO_MODERN_PLURALS.has(kindLower) && isModernKyvernoPolicy(resource)) {
5647
+ return <KyvernoModernPolicyCell resource={resource} column={column} />
5648
+ }
5649
+ if (kindLower === 'policyexceptions' && isAnyKyvernoPolicyException(resource)) {
5650
+ return <KyvernoPolicyExceptionCell resource={resource} column={column} />
5651
+ }
5652
+ if (
5653
+ (kindLower === 'cleanuppolicies' || kindLower === 'clustercleanuppolicies') &&
5654
+ typeof resource?.apiVersion === 'string' &&
5655
+ resource.apiVersion.startsWith('kyverno.io/')
5656
+ ) {
5657
+ return <KyvernoCleanupPolicyCell resource={resource} column={column} />
5658
+ }
5659
+
5458
5660
  switch (kindLower) {
5459
5661
  case 'pods':
5460
5662
  return <PodCell resource={resource} column={column} />
@@ -5644,26 +5846,53 @@ function CellContent({ resource, kind, column, group, majorityNodeMinorVersion,
5644
5846
  case 'clustersecretstores':
5645
5847
  return <ClusterSecretStoreCell resource={resource} column={column} />
5646
5848
  // Velero
5849
+ case 'cnpgbackups':
5850
+ return <CNPGBackupCell resource={resource} column={column} />
5647
5851
  case 'backups':
5648
- // Disambiguate CNPG vs Velero backups by apiVersion
5649
- if (resource.apiVersion?.includes('cnpg.io')) {
5852
+ // Reached only when the group is unknown to normalizeKindToPlural. Both
5853
+ // engines are matched positively so a third `backups` CRD renders generic
5854
+ // rather than inheriting whichever branch happened to be the fallback.
5855
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5650
5856
  return <CNPGBackupCell resource={resource} column={column} />
5651
5857
  }
5652
- return <BackupCell resource={resource} column={column} />
5653
- case 'restores':
5858
+ if (isApiGroup(resource.apiVersion, 'velero.io')) {
5859
+ return <BackupCell resource={resource} column={column} />
5860
+ }
5861
+ return <GenericCell resource={resource} column={column} />
5862
+ case 'velerorestores':
5654
5863
  return <RestoreCell resource={resource} column={column} />
5655
- case 'schedules':
5864
+ case 'veleroschedules':
5656
5865
  return <ScheduleCell resource={resource} column={column} />
5657
5866
  case 'backupstoragelocations':
5867
+ if (!isVeleroResource(resource)) return <GenericCell resource={resource} column={column} />
5658
5868
  return <BackupStorageLocationCell resource={resource} column={column} />
5869
+ case 'volumesnapshotlocations':
5870
+ if (!isVeleroResource(resource)) return <GenericCell resource={resource} column={column} />
5871
+ return <VolumeSnapshotLocationCell resource={resource} column={column} />
5872
+ case 'backuprepositories':
5873
+ if (!isVeleroResource(resource)) return <GenericCell resource={resource} column={column} />
5874
+ return <BackupRepositoryCell resource={resource} column={column} />
5659
5875
  // CloudNativePG
5660
5876
  case 'cnpgclusters':
5661
- case 'clusters':
5662
5877
  return <CNPGClusterCell resource={resource} column={column} />
5878
+ case 'clusters':
5879
+ // Positive guard: `clusters` is one of the most collided CRD plurals
5880
+ // (CNPG, CAPI, KubeBlocks, Redis/Valkey operators). Anything else gets
5881
+ // the generic cell instead of a fabricated Postgres status.
5882
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5883
+ return <CNPGClusterCell resource={resource} column={column} />
5884
+ }
5885
+ return <GenericCell resource={resource} column={column} />
5663
5886
  case 'scheduledbackups':
5664
- return <CNPGScheduledBackupCell resource={resource} column={column} />
5887
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5888
+ return <CNPGScheduledBackupCell resource={resource} column={column} />
5889
+ }
5890
+ return <GenericCell resource={resource} column={column} />
5665
5891
  case 'poolers':
5666
- return <CNPGPoolerCell resource={resource} column={column} />
5892
+ if (isApiGroup(resource.apiVersion, CNPG_GROUP)) {
5893
+ return <CNPGPoolerCell resource={resource} column={column} />
5894
+ }
5895
+ return <GenericCell resource={resource} column={column} />
5667
5896
  // Istio Service Mesh
5668
5897
  case 'virtualservices':
5669
5898
  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
+ })