@skyhook-io/k8s-ui 1.12.0 → 1.13.3

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 (117) hide show
  1. package/package.json +4 -4
  2. package/src/components/applications/ApplicationDetail.tsx +2 -2
  3. package/src/components/cluster-switcher/ClusterSwitcher.tsx +32 -7
  4. package/src/components/issues/severity.ts +1 -0
  5. package/src/components/resources/ResourcesView.empty-note.test.ts +41 -0
  6. package/src/components/resources/ResourcesView.tsx +419 -9
  7. package/src/components/resources/index.ts +27 -0
  8. package/src/components/resources/kyverno-legacy-action.test.ts +98 -0
  9. package/src/components/resources/renderers/AnalysisRunRenderer.test.tsx +241 -0
  10. package/src/components/resources/renderers/AnalysisRunRenderer.tsx +166 -0
  11. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +31 -2
  12. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +33 -0
  13. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +47 -4
  14. package/src/components/resources/renderers/CNPGDeclarativeRenderer.tsx +243 -0
  15. package/src/components/resources/renderers/CNPGObjectStoreRenderer.test.tsx +99 -0
  16. package/src/components/resources/renderers/CNPGObjectStoreRenderer.tsx +235 -0
  17. package/src/components/resources/renderers/CNPGScheduledBackupRenderer.tsx +23 -1
  18. package/src/components/resources/renderers/CNPGShared.tsx +27 -0
  19. package/src/components/resources/renderers/CalicoHostEndpointRenderer.tsx +71 -0
  20. package/src/components/resources/renderers/CalicoIPPoolRenderer.tsx +32 -0
  21. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.test.tsx +205 -0
  22. package/src/components/resources/renderers/CalicoNetworkPolicyDiagram.tsx +537 -0
  23. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.test.tsx +223 -0
  24. package/src/components/resources/renderers/CalicoNetworkPolicyRenderer.tsx +670 -0
  25. package/src/components/resources/renderers/CalicoTierRenderer.tsx +19 -0
  26. package/src/components/resources/renderers/KyvernoCELPolicyRenderers.tsx +13 -6
  27. package/src/components/resources/renderers/KyvernoExceptionRenderers.tsx +3 -2
  28. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.test.ts +31 -0
  29. package/src/components/resources/renderers/KyvernoGlobalContextRenderer.tsx +124 -0
  30. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +28 -2
  31. package/src/components/resources/renderers/KyvernoQueueRenderers.tsx +303 -0
  32. package/src/components/resources/renderers/LookupFailureNote.test.tsx +60 -0
  33. package/src/components/resources/renderers/LookupFailureNote.tsx +42 -0
  34. package/src/components/resources/renderers/NamespaceRenderer.test.ts +30 -0
  35. package/src/components/resources/renderers/NamespaceRenderer.tsx +7 -3
  36. package/src/components/resources/renderers/NetworkPolicyDiagram.tsx +27 -26
  37. package/src/components/resources/renderers/NetworkPolicyRenderer.tsx +6 -3
  38. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +348 -0
  39. package/src/components/resources/renderers/PolicyCoverageSection.tsx +939 -0
  40. package/src/components/resources/renderers/PolicySection.test.tsx +59 -2
  41. package/src/components/resources/renderers/PolicySection.tsx +32 -5
  42. package/src/components/resources/renderers/RolloutRenderer.test.tsx +491 -0
  43. package/src/components/resources/renderers/RolloutRenderer.tsx +391 -92
  44. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +374 -0
  45. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +179 -3
  46. package/src/components/resources/renderers/VeleroBackupRenderer.test.tsx +221 -0
  47. package/src/components/resources/renderers/VeleroBackupRenderer.tsx +119 -6
  48. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.test.tsx +69 -0
  49. package/src/components/resources/renderers/VeleroBackupRepositoryRenderer.tsx +96 -0
  50. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +55 -0
  51. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +22 -4
  52. package/src/components/resources/renderers/VeleroRunMessages.test.tsx +152 -0
  53. package/src/components/resources/renderers/VeleroRunMessages.tsx +139 -0
  54. package/src/components/resources/renderers/VeleroScheduleRenderer.test.tsx +88 -0
  55. package/src/components/resources/renderers/VeleroScheduleRenderer.tsx +16 -6
  56. package/src/components/resources/renderers/calico-cells.tsx +177 -0
  57. package/src/components/resources/renderers/cnpg-cells.tsx +100 -0
  58. package/src/components/resources/renderers/index.ts +16 -1
  59. package/src/components/resources/renderers/kyverno-cells.tsx +81 -0
  60. package/src/components/resources/renderers/network-policy-peer-styles.ts +25 -0
  61. package/src/components/resources/renderers/velero-cells.tsx +29 -7
  62. package/src/components/resources/renderers/velero-dead-ends.test.tsx +142 -0
  63. package/src/components/resources/resource-utils-calico.test.ts +215 -0
  64. package/src/components/resources/resource-utils-calico.ts +261 -0
  65. package/src/components/resources/resource-utils-cnpg-declarative.test.ts +104 -0
  66. package/src/components/resources/resource-utils-cnpg-objectstore.test.ts +122 -0
  67. package/src/components/resources/resource-utils-cnpg.test.ts +54 -0
  68. package/src/components/resources/resource-utils-cnpg.ts +264 -3
  69. package/src/components/resources/resource-utils-kyverno-modern.ts +24 -0
  70. package/src/components/resources/resource-utils-kyverno-queue.test.ts +164 -0
  71. package/src/components/resources/resource-utils-kyverno-queue.ts +198 -0
  72. package/src/components/resources/resource-utils-kyverno.ts +43 -7
  73. package/src/components/resources/resource-utils-velero.test.ts +26 -0
  74. package/src/components/resources/resource-utils-velero.ts +68 -0
  75. package/src/components/resources/resource-utils.ts +98 -0
  76. package/src/components/shared/ResourceActionsBar.tsx +101 -16
  77. package/src/components/shared/ResourceRendererDispatch.test.tsx +379 -3
  78. package/src/components/shared/ResourceRendererDispatch.tsx +199 -22
  79. package/src/components/shared/index.ts +1 -1
  80. package/src/components/shared/revision-role-badges.test.ts +120 -0
  81. package/src/components/topology/K8sResourceNode.test.ts +10 -0
  82. package/src/components/topology/K8sResourceNode.tsx +22 -0
  83. package/src/components/topology/TopologyFilterSidebar.test.tsx +31 -0
  84. package/src/components/topology/TopologyFilterSidebar.tsx +5 -0
  85. package/src/components/topology/TopologyGraph.tsx +6 -6
  86. package/src/components/topology/TopologySearch.tsx +7 -0
  87. package/src/components/topology/layout.ts +3 -0
  88. package/src/components/trace/ReachabilityView.tsx +1 -0
  89. package/src/components/trace/TracePanel.tsx +13 -2
  90. package/src/components/trace/reachFixtures.ts +26 -1
  91. package/src/components/trace/reachGraphModel.test.ts +20 -0
  92. package/src/components/trace/reachGraphModel.ts +8 -1
  93. package/src/components/trace/reachOrigins.test.ts +31 -0
  94. package/src/components/trace/reachOrigins.ts +11 -1
  95. package/src/components/ui/Badge.tsx +10 -0
  96. package/src/components/ui/InClusterConsentDialog.tsx +5 -4
  97. package/src/components/ui/ScheduleValue.test.tsx +80 -0
  98. package/src/components/ui/ScheduleValue.tsx +85 -0
  99. package/src/components/ui/SelectMenu.tsx +202 -28
  100. package/src/components/ui/drawer-components.test.tsx +17 -1
  101. package/src/components/ui/drawer-components.tsx +9 -2
  102. package/src/components/ui/index.ts +2 -0
  103. package/src/components/workload/WorkloadView.tsx +5 -2
  104. package/src/types/core.ts +36 -4
  105. package/src/types/policy.ts +153 -2
  106. package/src/utils/clipboard.test.ts +86 -0
  107. package/src/utils/clipboard.ts +50 -0
  108. package/src/utils/extended-resources.ts +4 -7
  109. package/src/utils/format.test.ts +83 -1
  110. package/src/utils/format.ts +80 -1
  111. package/src/utils/inClusterConsent.test.ts +9 -0
  112. package/src/utils/inClusterConsent.ts +9 -9
  113. package/src/utils/index.ts +1 -0
  114. package/src/utils/navigation.test.ts +5 -0
  115. package/src/utils/navigation.ts +5 -0
  116. package/src/utils/resource-icons.test.ts +49 -2
  117. package/src/utils/resource-icons.ts +29 -2
@@ -82,6 +82,9 @@ interface ResourceActionsBarProps {
82
82
  revisionsError?: Error | null
83
83
  onRollback?: (params: { kind: string; namespace: string; name: string; revision: number }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
84
84
  isRollingBack?: boolean
85
+ // A rolled-back Rollout re-enters its strategy: canary replays every step,
86
+ // blueGreen parks the revision in preview. Absent when promote-full is denied.
87
+ onRolloutPromoteFull?: (params: { namespace: string; name: string }) => void | Promise<unknown>
85
88
 
86
89
  // CronJob actions
87
90
  onTriggerCronJob?: (params: { namespace: string; name: string }) => void
@@ -134,6 +137,7 @@ export function ResourceActionsBar({
134
137
  onDelete, isDeleting, cascadeDependents, cascadeLoading, cascadeRootResolved,
135
138
  onRestart, isRestarting,
136
139
  revisions: revisionsList, revisionsLoading, revisionsError, onRollback, isRollingBack,
140
+ onRolloutPromoteFull,
137
141
  onTriggerCronJob, isTriggeringCronJob,
138
142
  onSuspendCronJob, isSuspendingCronJob,
139
143
  onResumeCronJob, isResumingCronJob,
@@ -169,7 +173,7 @@ export function ResourceActionsBar({
169
173
 
170
174
  // Rollback dialog state
171
175
  const [showRevisions, setShowRevisions] = useState(false)
172
- const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets'].includes(kind)
176
+ const isRollbackKind = ['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(kind)
173
177
  const hasMultipleRevisions = (revisionsList?.length ?? 0) > 1
174
178
 
175
179
  function handleDeleteConfirm(force: boolean) {
@@ -662,7 +666,7 @@ export function ResourceActionsBar({
662
666
  </div>
663
667
  </ConfirmDialog>
664
668
 
665
- {showRevisions && ['deployments', 'statefulsets', 'daemonsets'].includes(kind) && (
669
+ {showRevisions && isRollbackKind && (
666
670
  <RevisionHistoryDialog
667
671
  kind={resource.kind}
668
672
  namespace={resource.namespace}
@@ -674,6 +678,7 @@ export function ResourceActionsBar({
674
678
  error={revisionsError}
675
679
  onRollback={onRollback}
676
680
  isRollingBack={isRollingBack}
681
+ onRolloutPromoteFull={onRolloutPromoteFull}
677
682
  />
678
683
  )}
679
684
  </div>
@@ -879,7 +884,54 @@ function RevisionImage({ image, displayImage }: { image: string; displayImage: s
879
884
  )
880
885
  }
881
886
 
882
- export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, revisions, isLoading, error, onRollback, isRollingBack }: {
887
+ // Accepts both the singular Kind and the plural route segment.
888
+ export function isRolloutKind(kind: string): boolean {
889
+ return kind.toLowerCase().startsWith('rollout')
890
+ }
891
+
892
+ // Deliberately not strategy-gated: canary replays its steps and blueGreen parks
893
+ // the revision in preview, so both need promote-full to land a rollback.
894
+ export function offersPromoteAfterRollback(kind: string, hasPromoteFull: boolean): boolean {
895
+ return isRolloutKind(kind) && hasPromoteFull
896
+ }
897
+
898
+ export interface RevisionRoleBadge {
899
+ label: 'Stable' | 'Current' | 'Rolling out'
900
+ tone: 'status-healthy' | 'status-degraded'
901
+ tip?: string
902
+ }
903
+
904
+ // Mid-canary a Rollout's current revision is not yet the stable one; for every
905
+ // other workload kind the two always coincide.
906
+ export function revisionRoleBadges(rev: WorkloadRevision, isRollout: boolean): RevisionRoleBadge[] {
907
+ const badges: RevisionRoleBadge[] = []
908
+ if (rev.isStable && !rev.isCurrent) {
909
+ badges.push({ label: 'Stable', tone: 'status-healthy', tip: 'Serving stable traffic — an abort reverts here' })
910
+ }
911
+ if (rev.isCurrent) {
912
+ const settled = rev.isStable || !isRollout
913
+ badges.push({ label: settled ? 'Current' : 'Rolling out', tone: settled ? 'status-healthy' : 'status-degraded' })
914
+ }
915
+ return badges
916
+ }
917
+
918
+ // Awaits promote-full so the dialog cannot close on a half-landed rollback. A
919
+ // failure is reported by the mutation's error toast, so it must not block the close.
920
+ export async function completePromoteAfterRollback(
921
+ promote: () => void | Promise<unknown>,
922
+ setPending: (pending: boolean) => void,
923
+ ): Promise<void> {
924
+ setPending(true)
925
+ try {
926
+ await promote()
927
+ } catch {
928
+ // already surfaced to the user
929
+ } finally {
930
+ setPending(false)
931
+ }
932
+ }
933
+
934
+ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, revisions, isLoading, error, onRollback, isRollingBack, onRolloutPromoteFull }: {
883
935
  kind: string
884
936
  namespace: string
885
937
  name: string
@@ -890,12 +942,22 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
890
942
  error?: Error | null
891
943
  onRollback?: (params: { kind: string; namespace: string; name: string; revision: number }, callbacks?: { onSuccess?: () => void; onError?: (err: unknown) => void }) => void
892
944
  isRollingBack?: boolean
945
+ onRolloutPromoteFull?: (params: { namespace: string; name: string }) => void | Promise<unknown>
893
946
  }) {
894
947
  const [confirmRevision, setConfirmRevision] = useState<number | null>(null)
895
948
  const [diffRevision, setDiffRevision] = useState<number | null>(null)
949
+ const [promoteAfterRollback, setPromoteAfterRollback] = useState(false)
950
+ const [promotingFull, setPromotingFull] = useState(false)
896
951
 
897
952
  const handleClose = () => { setDiffRevision(null); onClose() }
898
953
 
954
+ // The dialog must not close while promote-full is still in flight, or a rollback
955
+ // that only half-landed reads as complete.
956
+ const busy = Boolean(isRollingBack) || promotingFull
957
+
958
+ const isRollout = isRolloutKind(kind)
959
+ const canPromoteAfterRollback = offersPromoteAfterRollback(kind, Boolean(onRolloutPromoteFull))
960
+
899
961
  const currentRevision = revisions?.find(r => r.isCurrent)
900
962
  const selectedRevision = revisions?.find(r => r.number === diffRevision)
901
963
  const hasDiffData = currentRevision?.template && selectedRevision?.template
@@ -904,7 +966,13 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
904
966
  onRollback?.(
905
967
  { kind, namespace, name, revision },
906
968
  {
907
- onSuccess: () => {
969
+ onSuccess: async () => {
970
+ if (canPromoteAfterRollback && promoteAfterRollback && onRolloutPromoteFull) {
971
+ await completePromoteAfterRollback(
972
+ () => onRolloutPromoteFull({ namespace, name }),
973
+ setPromotingFull,
974
+ )
975
+ }
908
976
  setConfirmRevision(null)
909
977
  setDiffRevision(null)
910
978
  onClose()
@@ -938,7 +1006,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
938
1006
  <DialogPortal
939
1007
  open={open}
940
1008
  onClose={handleClose}
941
- closable={!isRollingBack}
1009
+ closable={!busy}
942
1010
  className="flex max-h-[85vh] w-[calc(100vw-2rem)] max-w-5xl flex-col"
943
1011
  >
944
1012
  <div className="flex items-center justify-between p-4 border-b border-theme-border shrink-0">
@@ -955,7 +1023,7 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
955
1023
  <Tooltip content="Close" delay={150}>
956
1024
  <button
957
1025
  onClick={handleClose}
958
- disabled={isRollingBack}
1026
+ disabled={busy}
959
1027
  aria-label="Close revision history"
960
1028
  className="p-1 text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded disabled:opacity-50"
961
1029
  >
@@ -1036,22 +1104,27 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
1036
1104
  </button>
1037
1105
  </Tooltip>
1038
1106
  )}
1039
- {rev.isCurrent ? (
1040
- <span className="badge status-healthy">
1041
- Current
1042
- </span>
1043
- ) : confirmRevision === rev.number ? (
1107
+ {revisionRoleBadges(rev, isRollout).map(({ label, tone, tip }) =>
1108
+ tip ? (
1109
+ <Tooltip key={label} content={tip} delay={150}>
1110
+ <span className={clsx('badge', tone)}>{label}</span>
1111
+ </Tooltip>
1112
+ ) : (
1113
+ <span key={label} className={clsx('badge', tone)}>{label}</span>
1114
+ )
1115
+ )}
1116
+ {rev.isCurrent ? null : confirmRevision === rev.number ? (
1044
1117
  <>
1045
1118
  <button
1046
1119
  onClick={() => handleRollback(rev.number)}
1047
- disabled={isRollingBack}
1120
+ disabled={busy}
1048
1121
  className="px-2 py-0.5 text-xs font-medium text-white bg-amber-600 hover:bg-amber-700 rounded transition-colors disabled:opacity-50"
1049
1122
  >
1050
- {isRollingBack ? 'Rolling back...' : 'Confirm'}
1123
+ {promotingFull ? 'Promoting...' : isRollingBack ? 'Rolling back...' : 'Confirm'}
1051
1124
  </button>
1052
1125
  <button
1053
1126
  onClick={() => setConfirmRevision(null)}
1054
- disabled={isRollingBack}
1127
+ disabled={busy}
1055
1128
  className="px-2 py-0.5 text-xs font-medium text-theme-text-secondary hover:text-theme-text-primary rounded transition-colors disabled:opacity-50"
1056
1129
  >
1057
1130
  Cancel
@@ -1084,10 +1157,22 @@ export function RevisionHistoryDialog({ kind, namespace, name, open, onClose, re
1084
1157
  )}
1085
1158
  </div>
1086
1159
 
1087
- <div className="flex items-center justify-end p-4 border-t border-theme-border shrink-0">
1160
+ <div className="flex items-center justify-between gap-4 p-4 border-t border-theme-border shrink-0">
1161
+ {canPromoteAfterRollback ? (
1162
+ <label className="flex items-center gap-2 text-sm text-theme-text-secondary">
1163
+ <input
1164
+ type="checkbox"
1165
+ checked={promoteAfterRollback}
1166
+ onChange={(e) => setPromoteAfterRollback(e.target.checked)}
1167
+ disabled={busy}
1168
+ className="rounded border-theme-border"
1169
+ />
1170
+ Promote fully after rollback — skip pauses, steps, and analysis (emergency hotfix)
1171
+ </label>
1172
+ ) : <span />}
1088
1173
  <button
1089
1174
  onClick={handleClose}
1090
- disabled={isRollingBack}
1175
+ disabled={busy}
1091
1176
  className="px-4 py-2 text-sm font-medium text-theme-text-secondary hover:text-theme-text-primary hover:bg-theme-elevated rounded-lg transition-colors disabled:opacity-50"
1092
1177
  >
1093
1178
  Close
@@ -44,13 +44,14 @@ describe('ResourceRendererDispatch', () => {
44
44
  })
45
45
  })
46
46
 
47
- function renderKind(kind: string, data: any, namespace = ''): string {
47
+ function renderKind(kind: string, data: any, namespace = '', onNavigate?: (ref: ResourceRef) => void): string {
48
48
  return renderToString(
49
49
  <ResourceRendererDispatch
50
50
  resource={{ kind, namespace, name: data?.metadata?.name || 'x' }}
51
51
  data={data}
52
52
  onCopy={() => {}}
53
53
  copied={null}
54
+ onNavigate={onNavigate}
54
55
  showCommonSections={false}
55
56
  />,
56
57
  )
@@ -153,11 +154,24 @@ describe('DRA renderers dispatch', () => {
153
154
  // so any third operator's CRD inherited whichever branch was the fallback.
154
155
  // ============================================================================
155
156
 
157
+ // The spec field is a probe: GenericRenderer renders spec into a Specification
158
+ // section, so its value appearing in the output is proof the fall-through
159
+ // reached a renderer. A blank drawer is not an empty string — the surrounding
160
+ // wrapper still emits ~50 characters of markup — so asserting non-emptiness
161
+ // passes even when the drawer shows the user nothing.
162
+ const COLLISION_PROBE = 'collision-probe-value'
163
+
156
164
  function renderCollidingKind(kind: string, apiVersion: string): string {
157
165
  return renderToString(
158
166
  <ResourceRendererDispatch
159
167
  resource={{ kind, namespace: 'default', name: 'thing' }}
160
- data={{ apiVersion, kind: 'Cluster', metadata: { name: 'thing', namespace: 'default' }, spec: {}, status: {} }}
168
+ data={{
169
+ apiVersion,
170
+ kind: 'Cluster',
171
+ metadata: { name: 'thing', namespace: 'default' },
172
+ spec: { collisionProbe: COLLISION_PROBE },
173
+ status: {},
174
+ }}
161
175
  onCopy={() => {}}
162
176
  copied={null}
163
177
  showCommonSections={false}
@@ -225,8 +239,40 @@ describe('ResourceRendererDispatch — colliding plurals fall through', () => {
225
239
  ['backups', 'kubevirt.io/v1'],
226
240
  ['scheduledbackups', 'other.io/v1'],
227
241
  ['poolers', 'other.io/v1'],
242
+ // The CNPG declarative kinds and the barman-cloud store extend the same
243
+ // list. `databases` and `publications` ship with several database
244
+ // operators, `objectstores` is a generic plural, and `subscriptions` is
245
+ // Knative's as well — every one of them was blank until this fall-through
246
+ // covered it.
247
+ ['objectstores', 'other.io/v1'],
248
+ ['databases', 'mysql.oracle.com/v2'],
249
+ ['publications', 'other.io/v1'],
250
+ ['subscriptions', 'operators.coreos.com/v1alpha1'],
251
+ ['imagecatalogs', 'other.io/v1'],
252
+ ['clusterimagecatalogs', 'other.io/v1'],
253
+ ['policies', 'operators.coreos.com/v1'],
254
+ // Velero's BackupRepository joined KNOWN_KINDS when it got a renderer, so
255
+ // it needs the same fall-through as its siblings.
256
+ ['backuprepositories', 'other.io/v1'],
228
257
  ])('renders something for a foreign %s CRD', (kind, apiVersion) => {
229
- expect(renderCollidingKind(kind, apiVersion).trim()).not.toBe('')
258
+ expect(renderCollidingKind(kind, apiVersion)).toContain(COLLISION_PROBE)
259
+ })
260
+
261
+ // A Velero BackupRepository must reach its own renderer, not the generic one.
262
+ // It raises BackupRepositoryNotReady, and the reason Velero recorded is only
263
+ // on the dedicated page.
264
+ it('renders the Velero repository page for a velero.io backuprepositories', () => {
265
+ const html = renderCollidingKind('backuprepositories', 'velero.io/v1')
266
+ expect(html).toContain('Volume Namespace')
267
+ expect(html).not.toContain(COLLISION_PROBE)
268
+ })
269
+
270
+ // Both owners of `subscriptions` still get their own renderer.
271
+ it.each([
272
+ ['messaging.knative.dev/v1', 'Subscriber'],
273
+ ['postgresql.cnpg.io/v1', 'Applied'],
274
+ ])('still renders the owning engine for subscriptions/%s', (apiVersion, marker) => {
275
+ expect(renderCollidingKind('subscriptions', apiVersion)).toContain(marker)
230
276
  })
231
277
 
232
278
  it('does not double-render when a known engine matches', () => {
@@ -237,6 +283,273 @@ describe('ResourceRendererDispatch — colliding plurals fall through', () => {
237
283
  })
238
284
  })
239
285
 
286
+ describe('Calico IPPool collision handling', () => {
287
+ it.each(['crd.projectcalico.org/v1', 'projectcalico.org/v3'])('renders IPPool details for %s', (apiVersion) => {
288
+ const html = renderKind('ippools', {
289
+ apiVersion,
290
+ kind: 'IPPool',
291
+ metadata: { name: 'workloads' },
292
+ spec: {
293
+ allowedUses: ['Workload', 'Tunnel'],
294
+ assignmentMode: 'Automatic',
295
+ blockSize: 26,
296
+ cidr: '172.16.0.0/16',
297
+ ipipMode: 'CrossSubnet',
298
+ vxlanMode: 'Never',
299
+ natOutgoing: true,
300
+ disabled: false,
301
+ disableBGPExport: true,
302
+ nodeSelector: 'all()',
303
+ namespaceSelector: "environment == 'production'",
304
+ },
305
+ })
306
+
307
+ expect(html).toContain('IP Pool')
308
+ expect(html).toContain('Allowed Uses')
309
+ expect(html).toContain('Workload, Tunnel')
310
+ expect(html).toContain('Assignment Mode')
311
+ expect(html).toContain('Automatic')
312
+ expect(html).toContain('Block Size')
313
+ expect(html).toContain('26')
314
+ expect(html).toContain('CIDR')
315
+ expect(html).toContain('172.16.0.0/16')
316
+ expect(html).toContain('IP-in-IP Mode')
317
+ expect(html).toContain('CrossSubnet')
318
+ expect(html).toContain('VXLAN Mode')
319
+ expect(html).toContain('Never')
320
+ expect(html).toContain('NAT Outgoing')
321
+ expect(html).toContain('Yes')
322
+ expect(html).toContain('Disabled')
323
+ expect(html).toContain('No')
324
+ expect(html).toContain('BGP Export Disabled')
325
+ expect(html).toContain('Node Selector')
326
+ expect(html).toContain('all()')
327
+ expect(html).toContain('Namespace Selector')
328
+ expect(html).toContain('environment == &#x27;production&#x27;')
329
+ })
330
+
331
+ it('renders documented defaults when fields are omitted', () => {
332
+ const html = renderKind('ippools', {
333
+ apiVersion: 'projectcalico.org/v3',
334
+ kind: 'IPPool',
335
+ metadata: { name: 'default-ipv6' },
336
+ spec: { cidr: 'fd00::/48' },
337
+ })
338
+
339
+ expect(html).toContain('Workload, Tunnel')
340
+ expect(html).toContain('Automatic')
341
+ expect(html).toContain('122')
342
+ expect(html.match(/Never/g)).toHaveLength(2)
343
+ expect(html.match(/>No</g)).toHaveLength(3)
344
+ expect(html).toContain('all()')
345
+ })
346
+
347
+ it.each(['networking.example.io/v1', 'extension.projectcalico.org/v1'])('uses the generic renderer for %s', (apiVersion) => {
348
+ const html = renderKind('ippools', {
349
+ apiVersion,
350
+ kind: 'IPPool',
351
+ metadata: { name: 'foreign-pool' },
352
+ spec: { providerSpecificField: 'preserved' },
353
+ })
354
+
355
+ expect(html).toContain('Specification')
356
+ expect(html).toContain('Provider Specific Field')
357
+ expect(html).not.toContain('IP Pool')
358
+ })
359
+ })
360
+
361
+ describe('Calico HostEndpoint collision handling', () => {
362
+ it.each(['crd.projectcalico.org/v1', 'projectcalico.org/v3'])('renders HostEndpoint details for %s', (apiVersion) => {
363
+ const html = renderKind('hostendpoints', {
364
+ apiVersion,
365
+ kind: 'HostEndpoint',
366
+ metadata: { name: 'infra-1' },
367
+ spec: {
368
+ expectedIPs: ['172.20.16.133', '172.16.199.199'],
369
+ interfaceName: '*',
370
+ node: 'gdn-test-k8s-infra-1',
371
+ profiles: ['projectcalico-default-allow'],
372
+ ports: [{ name: 'ssh', protocol: 'TCP', port: 22 }],
373
+ },
374
+ }, '', () => {})
375
+
376
+ expect(html).toContain('Host Endpoint')
377
+ expect(html).toContain('Expected IPs')
378
+ expect(html).toContain('172.20.16.133')
379
+ expect(html).toContain('172.16.199.199')
380
+ expect(html).toContain('Interface Name')
381
+ expect(html).toContain('Node')
382
+ expect(html).toContain('<button')
383
+ expect(html).toContain('gdn-test-k8s-infra-1')
384
+ expect(html).toContain('Profiles')
385
+ expect(html).toContain('projectcalico-default-allow')
386
+ expect(html).toContain('Named Ports')
387
+ expect(html).toContain('ssh: TCP/22')
388
+ })
389
+
390
+ it.each(['networking.example.io/v1', 'extension.projectcalico.org/v1'])('uses the generic renderer for %s', (apiVersion) => {
391
+ const html = renderKind('hostendpoints', {
392
+ apiVersion,
393
+ kind: 'HostEndpoint',
394
+ metadata: { name: 'foreign-endpoint' },
395
+ spec: { providerSpecificField: 'preserved' },
396
+ })
397
+
398
+ expect(html).toContain('Specification')
399
+ expect(html).toContain('Provider Specific Field')
400
+ expect(html).not.toContain('Host Endpoint')
401
+ })
402
+ })
403
+
404
+ describe('Calico Tier collision handling', () => {
405
+ it.each(['crd.projectcalico.org/v1', 'projectcalico.org/v3'])('renders Tier details for %s', (apiVersion) => {
406
+ const html = renderKind('tiers', {
407
+ apiVersion,
408
+ kind: 'Tier',
409
+ metadata: { name: 'default' },
410
+ spec: { defaultAction: 'Deny', order: 1000000 },
411
+ })
412
+
413
+ expect(html).toContain('Tier')
414
+ expect(html).toContain('Default Action')
415
+ expect(html).toContain('Deny')
416
+ expect(html).toContain('Order')
417
+ expect(html).toContain('1000000')
418
+ })
419
+
420
+ it('renders documented defaults when fields are omitted', () => {
421
+ const html = renderKind('tiers', {
422
+ apiVersion: 'projectcalico.org/v3',
423
+ kind: 'Tier',
424
+ metadata: { name: 'default' },
425
+ spec: {},
426
+ })
427
+
428
+ expect(html).toContain('Deny')
429
+ expect(html).toContain('Last (lowest precedence)')
430
+ })
431
+
432
+ it.each(['networking.example.io/v1', 'extension.projectcalico.org/v1'])('uses the generic renderer for %s', (apiVersion) => {
433
+ const html = renderKind('tiers', {
434
+ apiVersion,
435
+ kind: 'Tier',
436
+ metadata: { name: 'foreign-tier' },
437
+ spec: { providerSpecificField: 'preserved' },
438
+ })
439
+
440
+ expect(html).toContain('Specification')
441
+ expect(html).toContain('Provider Specific Field')
442
+ expect(html).not.toContain('Default Action')
443
+ })
444
+ })
445
+
446
+ describe('Calico network policy collision handling', () => {
447
+ const calicoPolicies = [
448
+ ['NetworkPolicy', 'networkpolicies', 'CalicoNetworkPolicy'],
449
+ ['GlobalNetworkPolicy', 'globalnetworkpolicies', 'CalicoGlobalNetworkPolicy'],
450
+ ['StagedNetworkPolicy', 'stagednetworkpolicies', 'CalicoStagedNetworkPolicy'],
451
+ ['StagedGlobalNetworkPolicy', 'stagedglobalnetworkpolicies', 'CalicoStagedGlobalNetworkPolicy'],
452
+ ['StagedKubernetesNetworkPolicy', 'stagedkubernetesnetworkpolicies', 'CalicoStagedKubernetesNetworkPolicy'],
453
+ ] as const
454
+
455
+ it.each(calicoPolicies)('renders %s for both supported Calico groups', (kind, plural, label) => {
456
+ for (const apiVersion of ['crd.projectcalico.org/v1', 'projectcalico.org/v3']) {
457
+ const spec = kind === 'StagedKubernetesNetworkPolicy'
458
+ ? {
459
+ podSelector: { matchLabels: { app: 'api' } },
460
+ policyTypes: ['Ingress'],
461
+ stagedAction: 'Deny',
462
+ ingress: [{ from: [{ ipBlock: { cidr: '10.0.0.0/8' } }] }],
463
+ }
464
+ : {
465
+ selector: "app == 'api'",
466
+ tier: 'security',
467
+ order: 100,
468
+ types: ['Ingress'],
469
+ stagedAction: 'Deny',
470
+ ingress: [{ action: 'Log', protocol: 'TCP', source: { nets: ['10.0.0.0/8'] } }],
471
+ }
472
+ const html = renderKind(plural, {
473
+ apiVersion,
474
+ kind,
475
+ metadata: { name: 'policy', namespace: 'default' },
476
+ spec,
477
+ }, 'default')
478
+
479
+ if (kind === 'StagedKubernetesNetworkPolicy') {
480
+ expect(html).toContain('Target')
481
+ expect(html).toContain('Ingress Rules')
482
+ expect(html).toContain('Pod Selector')
483
+ expect(html).toContain('Staged preview')
484
+ expect(html).toContain('Dashed paths are evaluated but not enforced')
485
+ expect(html).toContain('stroke-dasharray="4 3"')
486
+ expect(html).not.toContain('CalicoStagedKubernetesNetworkPolicy')
487
+ expect(html).not.toContain('Allow')
488
+ } else {
489
+ expect(html).toContain(label)
490
+ expect(html).toContain('security')
491
+ expect(html).toContain('Log')
492
+ }
493
+ expect(html).toContain('10.0.0.0/8')
494
+ if (kind.startsWith('Staged') && kind !== 'StagedKubernetesNetworkPolicy') expect(html).toContain('Deny')
495
+ }
496
+ })
497
+
498
+ it('keeps core networking.k8s.io NetworkPolicy on the core renderer', () => {
499
+ const html = renderKind('networkpolicies', {
500
+ apiVersion: 'networking.k8s.io/v1',
501
+ kind: 'NetworkPolicy',
502
+ metadata: { name: 'core', namespace: 'default' },
503
+ spec: { podSelector: {}, policyTypes: ['Ingress'], ingress: [] },
504
+ }, 'default')
505
+
506
+ expect(html).toContain('Pod Selector')
507
+ expect(html).toContain('Deny all ingress')
508
+ expect(html).not.toContain('Calico NetworkPolicy')
509
+ })
510
+
511
+ it('uses the native staged Kubernetes presentation without an Allow action', () => {
512
+ const html = renderKind('stagedkubernetesnetworkpolicies', {
513
+ apiVersion: 'projectcalico.org/v3',
514
+ kind: 'StagedKubernetesNetworkPolicy',
515
+ metadata: { name: 'staged', namespace: 'default' },
516
+ spec: {
517
+ podSelector: { matchLabels: { app: 'api' } },
518
+ policyTypes: ['Ingress'],
519
+ ingress: [{}],
520
+ },
521
+ }, 'default')
522
+
523
+ expect(html).toContain('Target')
524
+ expect(html).toContain('Ingress Rules')
525
+ expect(html).toContain('All sources')
526
+ expect(html).toContain('Staged preview')
527
+ expect(html).toContain('Dashed paths are evaluated but not enforced')
528
+ expect(html).toContain('stroke-dasharray="4 3"')
529
+ expect(html).not.toContain('Allow')
530
+ })
531
+
532
+ it.each([
533
+ ['networkpolicies', 'other.example.io/v1', 'NetworkPolicy'],
534
+ ['networkpolicies', 'extension.projectcalico.org/v1', 'NetworkPolicy'],
535
+ ['globalnetworkpolicies', 'other.example.io/v1', 'GlobalNetworkPolicy'],
536
+ ['stagednetworkpolicies', 'other.example.io/v1', 'StagedNetworkPolicy'],
537
+ ['stagedglobalnetworkpolicies', 'other.example.io/v1', 'StagedGlobalNetworkPolicy'],
538
+ ['stagedkubernetesnetworkpolicies', 'other.example.io/v1', 'StagedKubernetesNetworkPolicy'],
539
+ ])('uses GenericRenderer for foreign %s/%s', (plural, apiVersion, kind) => {
540
+ const html = renderKind(plural, {
541
+ apiVersion,
542
+ kind,
543
+ metadata: { name: 'foreign', namespace: 'default' },
544
+ spec: { providerSpecificField: 'preserved' },
545
+ }, 'default')
546
+
547
+ expect(html).toContain('Specification')
548
+ expect(html).toContain('Provider Specific Field')
549
+ expect(html).not.toContain('Calico')
550
+ })
551
+ })
552
+
240
553
  describe('colliding plurals — near-match API groups', () => {
241
554
  // A substring guard would hand these to CNPG/Velero. They are different groups.
242
555
  it.each([
@@ -264,3 +577,66 @@ describe('colliding plurals — near-match API groups', () => {
264
577
  expect(html).not.toContain('Cluster Overview')
265
578
  })
266
579
  })
580
+
581
+ describe('shared plurals — the status path collides too', () => {
582
+ // The renderer and the status badge are two separate switches over the same
583
+ // plural, and fixing one leaves the other reading a foreign CRD's conditions
584
+ // as if they were Knative's.
585
+ it('does not give a foreign subscriptions CRD a Knative status', () => {
586
+ const s = getResourceStatus('subscriptions', {
587
+ apiVersion: 'operators.coreos.com/v1alpha1',
588
+ status: { conditions: [{ type: 'Ready', status: 'False' }], phase: 'AtLatestKnown' },
589
+ })
590
+ expect(s?.text).toBe('AtLatestKnown')
591
+ })
592
+
593
+ it('still gives Knative its own status', () => {
594
+ const s = getResourceStatus('subscriptions', {
595
+ apiVersion: 'messaging.knative.dev/v1',
596
+ status: { conditions: [{ type: 'Ready', status: 'True' }] },
597
+ })
598
+ expect(s?.text).toBe('Ready')
599
+ })
600
+
601
+ // The legacy namespaced Kyverno kind is served as `policies`, which several
602
+ // operators also use.
603
+ it('gives a Kyverno Policy its policy status and a foreign one the generic', () => {
604
+ const kyverno = getResourceStatus('policies', {
605
+ apiVersion: 'kyverno.io/v1',
606
+ spec: { validationFailureAction: 'Audit' },
607
+ status: { conditions: [{ type: 'Ready', status: 'True', reason: 'Succeeded' }] },
608
+ })
609
+ expect(kyverno?.text).toBeTruthy()
610
+ const foreign = getResourceStatus('policies', {
611
+ apiVersion: 'operators.coreos.com/v1',
612
+ status: { phase: 'Installed' },
613
+ })
614
+ expect(foreign?.text).toBe('Installed')
615
+ })
616
+ })
617
+
618
+ // A BackupRepository has custom table columns and raises its own issue, but no
619
+ // detail renderer — so it renders generically. Its status still has to resolve
620
+ // through the Velero mapping, or the drawer header prints the raw phase next to
621
+ // a table cell showing the human label for the same object.
622
+ describe('Velero BackupRepository status', () => {
623
+ const repo = (phase: string) => ({
624
+ apiVersion: 'velero.io/v1',
625
+ kind: 'BackupRepository',
626
+ metadata: { name: 'r', namespace: 'velero' },
627
+ spec: { repositoryType: 'kopia' },
628
+ status: { phase },
629
+ })
630
+
631
+ it('resolves through the Velero mapping, not the raw phase', () => {
632
+ expect(getResourceStatus('backuprepositories', repo('NotReady'))?.text).toBe('Not ready')
633
+ expect(getResourceStatus('backuprepositories', repo('Ready'))?.text).toBe('Ready')
634
+ })
635
+
636
+ // The plural is not Velero's alone; another group's repository must not pick
637
+ // up Velero's mapping.
638
+ it('leaves a foreign backuprepositories kind alone', () => {
639
+ const foreign = { ...repo('NotReady'), apiVersion: 'example.com/v1' }
640
+ expect(getResourceStatus('backuprepositories', foreign)?.text).not.toBe('Not ready')
641
+ })
642
+ })