@skyhook-io/k8s-ui 1.13.4 → 1.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/package.json +4 -4
  2. package/src/components/gitops/GitOpsTableView.tsx +168 -11
  3. package/src/components/gitops/applicationset-row.test.ts +214 -0
  4. package/src/components/gitops/detail-helpers.test.ts +54 -0
  5. package/src/components/gitops/detail-helpers.ts +13 -3
  6. package/src/components/gitops/index.ts +1 -0
  7. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +7 -0
  8. package/src/components/resources/ResourcesView.tsx +259 -80
  9. package/src/components/resources/column-header-width.test.ts +75 -0
  10. package/src/components/resources/kyverno-legacy-action.test.ts +100 -1
  11. package/src/components/resources/kyverno-modern-posture.test.ts +36 -0
  12. package/src/components/resources/renderers/CNPGBackupRenderer.test.tsx +29 -0
  13. package/src/components/resources/renderers/CNPGBackupRenderer.tsx +3 -2
  14. package/src/components/resources/renderers/CNPGClusterRenderer.test.tsx +69 -3
  15. package/src/components/resources/renderers/CNPGClusterRenderer.tsx +6 -2
  16. package/src/components/resources/renderers/KyvernoPolicyReportRenderer.tsx +12 -7
  17. package/src/components/resources/renderers/PolicyCoverageSection.test.tsx +92 -1
  18. package/src/components/resources/renderers/PolicyCoverageSection.tsx +64 -8
  19. package/src/components/resources/renderers/VeleroBSLRenderer.test.tsx +47 -0
  20. package/src/components/resources/renderers/VeleroBSLRenderer.tsx +16 -1
  21. package/src/components/resources/renderers/VeleroRestoreRenderer.test.tsx +88 -0
  22. package/src/components/resources/renderers/VeleroRestoreRenderer.tsx +44 -2
  23. package/src/components/resources/renderers/cnpg-cells.tsx +12 -1
  24. package/src/components/resources/renderers/kyverno-cells.tsx +8 -4
  25. package/src/components/resources/resource-utils-cnpg.golden.test.ts +13 -5
  26. package/src/components/resources/resource-utils-cnpg.test.ts +142 -2
  27. package/src/components/resources/resource-utils-cnpg.ts +132 -7
  28. package/src/components/resources/resource-utils-gpu-ecosystem.test.ts +14 -0
  29. package/src/components/resources/resource-utils-kueue.ts +4 -2
  30. package/src/components/resources/resource-utils-kyverno-modern.ts +3 -1
  31. package/src/components/resources/resource-utils-kyverno.ts +51 -2
  32. package/src/components/resources/resource-utils-velero.ts +32 -0
  33. package/src/components/resources/rules-column-width.test.ts +42 -0
  34. package/src/components/shared/ResourceRendererDispatch.test.tsx +75 -0
  35. package/src/components/shared/ResourceRendererDispatch.tsx +24 -6
  36. package/src/components/topology/GroupNode.tsx +6 -2
  37. package/src/components/topology/topology.css +4 -1
  38. package/src/components/ui/Collapse.tsx +9 -1
  39. package/src/components/ui/RowActionMenu.tsx +4 -0
  40. package/src/components/ui/SelectMenu.tsx +10 -1
  41. package/src/components/ui/drawer-components.tsx +4 -2
  42. package/src/components/workload/WorkloadView.tsx +9 -2
  43. package/src/types/gitops.ts +51 -0
@@ -503,6 +503,20 @@ const KNOWN_KINDS = new Set([
503
503
  'functions', 'configurations',
504
504
  ])
505
505
 
506
+ // Cluster topology owns resources across the core, bootstrap, control-plane,
507
+ // and infrastructure contracts. Keep this explicit: a suffix/substring match
508
+ // would also accept unrelated groups such as extension.cluster.x-k8s.io.
509
+ const CAPI_TOPOLOGY_GROUPS = [
510
+ 'cluster.x-k8s.io',
511
+ 'bootstrap.cluster.x-k8s.io',
512
+ 'controlplane.cluster.x-k8s.io',
513
+ 'infrastructure.cluster.x-k8s.io',
514
+ ] as const
515
+
516
+ function isCAPITopologyGroup(apiVersion?: string): boolean {
517
+ return CAPI_TOPOLOGY_GROUPS.some(group => isApiGroup(apiVersion, group))
518
+ }
519
+
506
520
  // ============================================================================
507
521
  // RESOURCE CONTENT - Delegates to specific renderers
508
522
  // ============================================================================
@@ -658,11 +672,13 @@ export function ResourceRendererDispatch({
658
672
  // them, and `subscriptions` is Knative's as well. Adding a kind to KNOWN_KINDS
659
673
  // with a positively-gated render line and forgetting this list is what makes a
660
674
  // foreign CRD render a BLANK drawer rather than the generic one.
675
+ // CAPI's `machines` and `machinesets` need the same protection because another
676
+ // API group can publish either plural without inheriting CAPI presentation.
661
677
  const isGroupGatedKind =
662
678
  kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
663
679
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
664
680
  || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs'
665
- || kind === 'policies' || kind === 'rollouts'
681
+ || kind === 'policies' || kind === 'rollouts' || kind === 'machines' || kind === 'machinesets'
666
682
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
667
683
  const groupGatedMatched =
668
684
  (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
@@ -675,6 +691,8 @@ export function ResourceRendererDispatch({
675
691
  && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'messaging.knative.dev')))
676
692
  || (kind === 'policies' && isApiGroup(data?.apiVersion, 'kyverno.io'))
677
693
  || (kind === 'rollouts' && isArgoRolloutResource(data))
694
+ || ((kind === 'machines' || kind === 'machinesets')
695
+ && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io'))
678
696
  const groupGatedFallthrough = isGroupGatedKind && !groupGatedMatched
679
697
 
680
698
  const calicoApiVersionMatched = isCalicoApiVersion(data?.apiVersion)
@@ -862,15 +880,15 @@ export function ResourceRendererDispatch({
862
880
  {kind === 'scheduledbackups' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGScheduledBackupRenderer data={data} onNavigate={onNavigate} />}
863
881
  {kind === 'poolers' && isApiGroup(data?.apiVersion, CNPG_GROUP) && <CNPGPoolerRenderer data={data} onNavigate={onNavigate} />}
864
882
  {/* Cluster API (CAPI) */}
865
- {'topology.cluster.x-k8s.io/owned' in (data?.metadata?.labels ?? {}) && data?.apiVersion?.includes('cluster.x-k8s.io') && (
883
+ {'topology.cluster.x-k8s.io/owned' in (data?.metadata?.labels ?? {}) && isCAPITopologyGroup(data?.apiVersion) && (
866
884
  <AlertBanner
867
885
  variant="warning"
868
886
  title="Topology-controlled — this resource is managed by ClusterClass. Manual changes will be reconciled back."
869
887
  />
870
888
  )}
871
- {kind === 'machines' && data?.apiVersion?.includes('cluster.x-k8s.io') && <CAPIMachineRenderer data={data} onNavigate={onNavigate} />}
889
+ {kind === 'machines' && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io') && <CAPIMachineRenderer data={data} onNavigate={onNavigate} />}
872
890
  {kind === 'machinedeployments' && <CAPIMachineDeploymentRenderer data={data} onNavigate={onNavigate} />}
873
- {kind === 'machinesets' && data?.apiVersion?.includes('cluster.x-k8s.io') && <CAPIMachineSetRenderer data={data} onNavigate={onNavigate} />}
891
+ {kind === 'machinesets' && isApiGroup(data?.apiVersion, 'cluster.x-k8s.io') && <CAPIMachineSetRenderer data={data} onNavigate={onNavigate} />}
874
892
  {kind === 'machinepools' && <CAPIMachinePoolRenderer data={data} onNavigate={onNavigate} />}
875
893
  {kind === 'kubeadmcontrolplanes' && <CAPIKubeadmControlPlaneRenderer data={data} onNavigate={onNavigate} />}
876
894
  {kind === 'clusterclasses' && <CAPIClusterClassRenderer data={data} />}
@@ -1187,9 +1205,9 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1187
1205
  // Third-party `clusters` CRDs (KubeBlocks, Redis/Valkey) fall through to the
1188
1206
  // generic handling below instead of getting a fabricated PostgreSQL status.
1189
1207
  }
1190
- if (k === 'machines' && data.apiVersion?.includes('cluster.x-k8s.io')) return getMachineStatus(data)
1208
+ if (k === 'machines' && isApiGroup(data.apiVersion, 'cluster.x-k8s.io')) return getMachineStatus(data)
1191
1209
  if (k === 'machinedeployments') return getMachineDeploymentStatus(data)
1192
- if (k === 'machinesets') return getMachineSetStatus(data)
1210
+ if (k === 'machinesets' && isApiGroup(data.apiVersion, 'cluster.x-k8s.io')) return getMachineSetStatus(data)
1193
1211
  if (k === 'machinepools') return getMachinePoolStatus(data)
1194
1212
  if (k === 'kubeadmcontrolplanes') return getKCPStatus(data)
1195
1213
  if (k === 'clusterclasses') return getClusterClassStatus(data)
@@ -159,6 +159,10 @@ export const GroupNode = memo(function GroupNode({
159
159
  </div>
160
160
  )
161
161
 
162
+ // Chip and cardGrid roots must NOT counter-scale with zoom (group-header-scaled):
163
+ // the transform shrinks the visuals but the React Flow wrapper — the hitbox,
164
+ // drag target, and edge-attachment box — keeps the full ELK-computed size, so
165
+ // past zoom 0.7 the interactive area extends well outside the drawn box.
162
166
  // ── Level 1: Chip (compact collapsed card) ──
163
167
  if (displayLevel === 'chip') {
164
168
  // Size tier: 0-9 → 0, 10-99 → 1, 100-999 → 2, 1000+ → 3
@@ -178,7 +182,7 @@ export const GroupNode = memo(function GroupNode({
178
182
  <>
179
183
  {handles}
180
184
  <div
181
- className="rounded-xl group-header-scaled overflow-hidden"
185
+ className="rounded-xl overflow-hidden"
182
186
  style={{ ...getBorderStyle(), ...getHeaderBgStyle(), padding: chipPadding, width: width || '100%', height: height || '100%' }}
183
187
  >
184
188
  {/* Header row: icon + name + count + status + controls */}
@@ -220,7 +224,7 @@ export const GroupNode = memo(function GroupNode({
220
224
  <>
221
225
  {handles}
222
226
  <div
223
- className="rounded-xl overflow-hidden group-header-scaled"
227
+ className="rounded-xl overflow-hidden"
224
228
  style={{ ...getBorderStyle(), width: width || '100%', height: height || '100%' }}
225
229
  >
226
230
  {/* Header bar with level controls */}
@@ -65,7 +65,10 @@
65
65
  box-shadow: none !important;
66
66
  }
67
67
 
68
- /* Group header scaling using CSS variable set by ViewportController */
68
+ /* Group header scaling using CSS variable set by ViewportController.
69
+ Only safe on children INSIDE a group container (the expanded-group header row):
70
+ on a node's root content it desyncs the visible box from the React Flow
71
+ wrapper, whose full-size hitbox and edge anchors don't follow the transform. */
69
72
  .group-header-scaled {
70
73
  transform: scale(var(--group-header-scale, 1));
71
74
  transform-origin: top left;
@@ -40,7 +40,15 @@ export function Collapse({
40
40
  className={clsx('grid transition-[grid-template-rows] duration-200 ease-out', className)}
41
41
  style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
42
42
  >
43
- <div className="overflow-hidden" inert={!open || undefined}>{render ? children : null}</div>
43
+ {/* `relative` is load-bearing. Collapsed content is still laid out at full
44
+ height and only clipped by `overflow-hidden` — but an absolutely-positioned
45
+ descendant (Tailwind's `sr-only` is position:absolute) resolves its
46
+ containing block ABOVE this wrapper, so the clip doesn't apply to it. It
47
+ then sits at its static position, hundreds of px below a collapsed section,
48
+ and extends the enclosing scroll container's scrollable overflow: the drawer
49
+ scrolls past its own content into blank space. Anchoring here puts those
50
+ boxes back inside the clip, so a collapsed section contributes nothing. */}
51
+ <div className="relative overflow-hidden" inert={!open || undefined}>{render ? children : null}</div>
44
52
  </div>
45
53
  )
46
54
  }
@@ -67,6 +67,10 @@ export function RowActionMenu({ items, ariaLabel = 'Row actions', compact = true
67
67
  const triggerSize = compact ? 'p-1' : 'p-1.5'
68
68
  const iconSize = compact ? 'h-4 w-4' : 'h-5 w-5'
69
69
 
70
+ // A trigger that opens an empty menu is a dead end - render nothing and let
71
+ // the cell collapse instead.
72
+ if (items.length === 0) return null
73
+
70
74
  return (
71
75
  <div ref={ref} className="relative inline-block">
72
76
  <button
@@ -12,15 +12,21 @@ export function SelectMenu({
12
12
  options,
13
13
  onChange,
14
14
  ariaLabel,
15
+ ariaDescribedBy,
15
16
  className,
16
17
  searchPlaceholder,
18
+ disabled = false,
19
+ id,
17
20
  }: {
18
21
  value: string
19
22
  options: SelectMenuOption[]
20
23
  onChange: (value: string) => void
21
24
  ariaLabel: string
25
+ ariaDescribedBy?: string
22
26
  className?: string
23
27
  searchPlaceholder?: string
28
+ disabled?: boolean
29
+ id?: string
24
30
  }) {
25
31
  const [open, setOpen] = useState(false)
26
32
  const [query, setQuery] = useState('')
@@ -109,8 +115,10 @@ export function SelectMenu({
109
115
  >
110
116
  <button
111
117
  ref={triggerRef}
118
+ id={id}
112
119
  type="button"
113
120
  aria-label={ariaLabel}
121
+ aria-describedby={ariaDescribedBy}
114
122
  aria-haspopup="listbox"
115
123
  aria-expanded={open}
116
124
  aria-controls={open ? listboxId : undefined}
@@ -125,7 +133,8 @@ export function SelectMenu({
125
133
  }
126
134
  setOpen(!open)
127
135
  }}
128
- className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover"
136
+ disabled={disabled}
137
+ className="flex h-8 w-full items-center justify-between gap-2 rounded-md border border-theme-border bg-theme-elevated px-2.5 text-xs text-theme-text-primary transition-colors hover:bg-theme-hover disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:bg-theme-elevated"
129
138
  >
130
139
  <span className="truncate">{selected?.label}</span>
131
140
  <ChevronDown className={clsx('h-3.5 w-3.5 shrink-0 text-theme-text-tertiary transition-transform', open && 'rotate-180')} />
@@ -101,7 +101,8 @@ export function Section({ title, icon: Icon, children, defaultExpanded = true, c
101
101
  className="grid transition-[grid-template-rows] duration-200 ease-out"
102
102
  style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
103
103
  >
104
- <div className="overflow-hidden">
104
+ {/* `relative` is load-bearing — see the note in ui/Collapse.tsx. */}
105
+ <div className="relative overflow-hidden">
105
106
  <div className={contentClassName ?? 'pl-6'}>{children}</div>
106
107
  </div>
107
108
  </div>
@@ -131,7 +132,8 @@ export function ExpandableSection({ title, children, defaultExpanded = true }: E
131
132
  className="grid transition-[grid-template-rows] duration-200 ease-out"
132
133
  style={{ gridTemplateRows: expanded ? '1fr' : '0fr' }}
133
134
  >
134
- <div className="overflow-hidden">
135
+ {/* `relative` is load-bearing — see the note in ui/Collapse.tsx. */}
136
+ <div className="relative overflow-hidden">
135
137
  <div className="ml-5">{children}</div>
136
138
  </div>
137
139
  </div>
@@ -880,8 +880,15 @@ export function WorkloadView({
880
880
  {/* Success animation overlay */}
881
881
  {saveSuccess && <SaveSuccessAnimation />}
882
882
 
883
- {/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
884
- <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
883
+ {/* Content — viewTransitionName scopes View Transitions API cross-fade to this element.
884
+ `relative` is the backstop for absolutely-positioned descendants (Tailwind's
885
+ `sr-only` is position:absolute). The drawer frame keeps overflow visible at idle so
886
+ popovers aren't clipped, and the app's content column clips only horizontally, so one
887
+ that resolves its containing block above this scroller escapes into the DOCUMENT's
888
+ scrollable overflow — a second, page-level scrollbar. Anchoring here confines the
889
+ damage to this scroll body; ui/Collapse.tsx keeps them from escaping in the first
890
+ place. */}
891
+ <div className="relative flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
885
892
  {!resource ? (
886
893
  // Fill the drawer body so the loading logo centers in it, not in a
887
894
  // 128px box pinned to the top (matches the splash/PaneLoader centering).
@@ -359,6 +359,57 @@ export function argoStatusToGitOpsStatus(status: ArgoAppStatus): GitOpsStatus {
359
359
  }
360
360
  }
361
361
 
362
+ /**
363
+ * Maps an Argo CD ApplicationSet's conditions to a GitOpsStatus.
364
+ *
365
+ * An ApplicationSet generates Applications; it does not apply declared state
366
+ * to a cluster itself. Sync answers "is the declared state applied", so there
367
+ * is no honest sync value here and it stays Unknown - the generated
368
+ * Applications each carry their own.
369
+ *
370
+ * Health answers whether the generator is doing its job, which its conditions
371
+ * do report, in a vocabulary of their own (ErrorOccurred / ParametersGenerated
372
+ * / ResourcesUpToDate - never Ready):
373
+ * - ErrorOccurred=True → Degraded, carrying the controller's message
374
+ * - ResourcesUpToDate=True → Healthy
375
+ * - neither → Unknown (status not populated yet)
376
+ *
377
+ * The Resources view renders the same three conditions through
378
+ * getArgoApplicationSetStatus for its own badge vocabulary. The two read the
379
+ * same signals and must agree on which condition wins.
380
+ */
381
+ export function argoApplicationSetConditionsToGitOpsStatus(
382
+ conditions: FluxCondition[]
383
+ ): GitOpsStatus {
384
+ const error = conditions.find(c => c.type === 'ErrorOccurred' && c.status === 'True')
385
+ if (error) {
386
+ return {
387
+ sync: 'Unknown',
388
+ health: 'Degraded',
389
+ message: error.message || 'Generation failed',
390
+ lastSyncTime: error.lastTransitionTime,
391
+ suspended: false,
392
+ }
393
+ }
394
+
395
+ const upToDate = conditions.find(c => c.type === 'ResourcesUpToDate')
396
+ if (upToDate?.status === 'True') {
397
+ return {
398
+ sync: 'Unknown',
399
+ health: 'Healthy',
400
+ lastSyncTime: upToDate.lastTransitionTime,
401
+ suspended: false,
402
+ }
403
+ }
404
+
405
+ return {
406
+ sync: 'Unknown',
407
+ health: 'Unknown',
408
+ message: 'Status cannot be determined',
409
+ suspended: false,
410
+ }
411
+ }
412
+
362
413
  // ============================================================================
363
414
  // INVENTORY PARSERS
364
415
  // ============================================================================