@skyhook-io/k8s-ui 1.14.0 → 1.14.2

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 (48) hide show
  1. package/package.json +1 -1
  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/issues/IssuesView.tsx +1 -1
  9. package/src/components/issues/types.ts +2 -0
  10. package/src/components/resources/ResourcesView.tsx +382 -141
  11. package/src/components/resources/column-header-width.test.ts +75 -0
  12. package/src/components/resources/column-settings-merge.test.ts +104 -0
  13. package/src/components/resources/istio-gateway-filter.test.ts +5 -3
  14. package/src/components/resources/istio-policy-accessors.test.ts +193 -0
  15. package/src/components/resources/renderers/IstioAuthorizationPolicyRenderer.tsx +11 -24
  16. package/src/components/resources/renderers/eso-cells.tsx +0 -5
  17. package/src/components/resources/renderers/istio-cells.tsx +36 -26
  18. package/src/components/resources/renderers/kueue-cells.tsx +0 -9
  19. package/src/components/resources/renderers/kyverno-cells.tsx +1 -1
  20. package/src/components/resources/renderers/prometheus-cells.tsx +3 -3
  21. package/src/components/resources/renderers/ray-cells.tsx +12 -4
  22. package/src/components/resources/renderers/volcano-cells.tsx +0 -9
  23. package/src/components/resources/resource-utils-cnpg.ts +5 -0
  24. package/src/components/resources/resource-utils-eso.test.ts +56 -0
  25. package/src/components/resources/resource-utils-eso.ts +37 -5
  26. package/src/components/resources/resource-utils-istio.ts +152 -44
  27. package/src/components/resources/resource-utils-kyverno.ts +3 -6
  28. package/src/components/resources/resource-utils-prometheus.ts +51 -23
  29. package/src/components/resources/resource-utils-ray.test.ts +36 -0
  30. package/src/components/resources/resource-utils-ray.ts +6 -0
  31. package/src/components/resources/resource-utils.ts +166 -18
  32. package/src/components/resources/rules-column-width.test.ts +42 -0
  33. package/src/components/resources/status-accessors.test.ts +363 -0
  34. package/src/components/resources/status-overclaim.test.ts +158 -0
  35. package/src/components/shared/ResourceRendererDispatch.test.tsx +75 -0
  36. package/src/components/shared/ResourceRendererDispatch.tsx +24 -6
  37. package/src/components/timeline/DiffViewer.test.tsx +26 -0
  38. package/src/components/timeline/DiffViewer.tsx +16 -12
  39. package/src/components/topology/GroupNode.tsx +6 -2
  40. package/src/components/topology/TopologyGraph.tsx +7 -2
  41. package/src/components/topology/topology.css +4 -1
  42. package/src/components/ui/Collapse.tsx +18 -3
  43. package/src/components/ui/ConfirmDialog.test.tsx +37 -0
  44. package/src/components/ui/ConfirmDialog.tsx +3 -1
  45. package/src/components/ui/RowActionMenu.tsx +4 -0
  46. package/src/components/ui/drawer-components.tsx +5 -3
  47. package/src/components/workload/WorkloadView.tsx +11 -3
  48. 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)
@@ -0,0 +1,26 @@
1
+ import { renderToStaticMarkup } from "react-dom/server";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { DiffViewer } from "./DiffViewer";
5
+
6
+ describe("DiffViewer", () => {
7
+ it("labels added, removed, and changed values without relying on color", () => {
8
+ const html = renderToStaticMarkup(
9
+ <DiffViewer
10
+ diff={{
11
+ summary: "Three fields changed",
12
+ fields: [
13
+ { path: "spec.added", oldValue: null, newValue: "on" },
14
+ { path: "spec.removed", oldValue: "on", newValue: null },
15
+ { path: "spec.changed", oldValue: "v1", newValue: "v2" },
16
+ ],
17
+ }}
18
+ />,
19
+ );
20
+
21
+ expect(html).toContain("Added value:");
22
+ expect(html).toContain("Removed value:");
23
+ expect(html).toContain("Old value:");
24
+ expect(html).toContain("New value:");
25
+ });
26
+ });
@@ -16,7 +16,7 @@ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: Di
16
16
  if (compact) {
17
17
  return (
18
18
  <div className="text-xs text-theme-text-secondary flex items-center gap-1">
19
- <RefreshCw className="w-3 h-3" />
19
+ <RefreshCw className="w-3 h-3" aria-hidden />
20
20
  <span>{diff.summary || `${diff.fields.length} field(s) changed`}</span>
21
21
  </div>
22
22
  )
@@ -27,7 +27,7 @@ export const DiffViewer = memo(function DiffViewer({ diff, compact = false }: Di
27
27
  {/* Summary */}
28
28
  {diff.summary && (
29
29
  <div className="text-sm font-medium text-theme-text-secondary flex items-center gap-2">
30
- <RefreshCw className="w-4 h-4 text-blue-400" />
30
+ <RefreshCw className="w-4 h-4 text-blue-400" aria-hidden />
31
31
  {diff.summary}
32
32
  </div>
33
33
  )}
@@ -54,31 +54,35 @@ function FieldChangeRow({ field }: FieldChangeRowProps) {
54
54
  return (
55
55
  <div className="rounded bg-theme-surface/50 border border-theme-border px-3 py-2">
56
56
  {/* Field path */}
57
- <div className="text-xs font-mono text-theme-text-tertiary mb-1">{field.path}</div>
57
+ <div className="text-xs font-mono text-theme-text-tertiary mb-1 break-words [overflow-wrap:anywhere]">{field.path}</div>
58
58
 
59
59
  {/* Values */}
60
- <div className="flex items-center gap-2 text-sm">
60
+ <div className="flex min-w-0 flex-wrap items-center gap-2 text-sm">
61
61
  {isAdded ? (
62
62
  <>
63
- <Plus className="w-3.5 h-3.5 text-green-400 shrink-0" />
64
- <span className="text-green-400">
63
+ <Plus className="w-3.5 h-3.5 text-green-400 shrink-0" aria-hidden />
64
+ <span className="sr-only">Added value: </span>
65
+ <span className="min-w-0 break-words text-green-400 [overflow-wrap:anywhere]">
65
66
  {formatValue(field.newValue)}
66
67
  </span>
67
68
  </>
68
69
  ) : isRemoved ? (
69
70
  <>
70
- <Minus className="w-3.5 h-3.5 text-red-400 shrink-0" />
71
- <span className="text-red-400 line-through">
71
+ <Minus className="w-3.5 h-3.5 text-red-400 shrink-0" aria-hidden />
72
+ <span className="sr-only">Removed value: </span>
73
+ <span className="min-w-0 break-words text-red-400 line-through [overflow-wrap:anywhere]">
72
74
  {formatValue(field.oldValue)}
73
75
  </span>
74
76
  </>
75
77
  ) : isModified ? (
76
78
  <>
77
- <span className="text-red-400 line-through">
79
+ <span className="sr-only">Old value: </span>
80
+ <span className="min-w-0 break-words text-red-400 line-through [overflow-wrap:anywhere]">
78
81
  {formatValue(field.oldValue)}
79
82
  </span>
80
- <ArrowRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" />
81
- <span className="text-green-400">
83
+ <ArrowRight className="w-3.5 h-3.5 text-theme-text-tertiary shrink-0" aria-hidden />
84
+ <span className="sr-only">New value: </span>
85
+ <span className="min-w-0 break-words text-green-400 [overflow-wrap:anywhere]">
82
86
  {formatValue(field.newValue)}
83
87
  </span>
84
88
  </>
@@ -124,7 +128,7 @@ export const DiffBadge = memo(function DiffBadge({ diff }: DiffBadgeProps) {
124
128
  'bg-skyhook-500/10 text-skyhook-400 border border-skyhook-500/20'
125
129
  )}
126
130
  >
127
- <RefreshCw className="w-3 h-3" />
131
+ <RefreshCw className="w-3 h-3" aria-hidden />
128
132
  {diff.summary}
129
133
  </span>
130
134
  )
@@ -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 */}
@@ -34,7 +34,7 @@ import { GroupNode } from './GroupNode'
34
34
  import { NEUTRAL_OWNER, type WorkloadFocus } from '../../utils/workload-colors'
35
35
  import { ownershipOf } from '../../utils/topology-neighborhood'
36
36
  import { buildHierarchicalElkGraph, applyHierarchicalLayout, getGroupKey, isGroupEffectivelyCollapsed, type GroupDisplayLevel } from './layout'
37
- import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode } from '../../types'
37
+ import type { Topology, TopologyNode, TopologyEdge, ViewMode, GroupingMode, HealthStatus } from '../../types'
38
38
  import { pluralize } from '../../utils/pluralize'
39
39
  import { foldHash } from '../../utils/structure-hash'
40
40
  import { recordLayoutDuration, recordLayoutSkipped, recordStructureKeyDuration } from '../../perf'
@@ -408,6 +408,7 @@ export function TopologyGraph({
408
408
  phase: string
409
409
  restarts: number
410
410
  containers: number
411
+ status?: HealthStatus
411
412
  }>
412
413
 
413
414
  // Find edges pointing to this pod group
@@ -425,7 +426,11 @@ export function TopologyGraph({
425
426
  id: podId,
426
427
  kind: 'Pod',
427
428
  name: pod.name,
428
- status: pod.phase === 'Running' ? 'healthy' : pod.phase === 'Pending' ? 'degraded' : 'unhealthy',
429
+ // The server computes pod health; pkg/health tracks a crash loop across
430
+ // the kubelet's Waiting->Running oscillation, which the phase alone
431
+ // hides — a crash-looping pod sits at Phase=Running. Deriving it here
432
+ // again would rebuild that logic in a second place and get it wrong.
433
+ status: pod.status ?? 'unknown',
429
434
  data: {
430
435
  ...podGroupNode.data,
431
436
  namespace: pod.namespace,
@@ -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;
@@ -37,10 +37,21 @@ export function Collapse({
37
37
  const render = !mountLazily || hasOpened
38
38
  return (
39
39
  <div
40
- className={clsx('grid transition-[grid-template-rows] duration-200 ease-out', className)}
40
+ className={clsx(
41
+ 'grid transition-[grid-template-rows] duration-200 ease-out motion-reduce:transition-none',
42
+ className,
43
+ )}
41
44
  style={{ gridTemplateRows: open ? '1fr' : '0fr' }}
42
45
  >
43
- <div className="overflow-hidden" inert={!open || undefined}>{render ? children : null}</div>
46
+ {/* `relative` is load-bearing. Collapsed content is still laid out at full
47
+ height and only clipped by `overflow-hidden` — but an absolutely-positioned
48
+ descendant (Tailwind's `sr-only` is position:absolute) resolves its
49
+ containing block ABOVE this wrapper, so the clip doesn't apply to it. It
50
+ then sits at its static position, hundreds of px below a collapsed section,
51
+ and extends the enclosing scroll container's scrollable overflow: the drawer
52
+ scrolls past its own content into blank space. Anchoring here puts those
53
+ boxes back inside the clip, so a collapsed section contributes nothing. */}
54
+ <div className="relative overflow-hidden" inert={!open || undefined}>{render ? children : null}</div>
44
55
  </div>
45
56
  )
46
57
  }
@@ -53,7 +64,11 @@ export function CollapseChevron({ open, className }: { open: boolean; className?
53
64
  return (
54
65
  <ChevronRight
55
66
  aria-hidden="true"
56
- className={clsx('shrink-0 text-theme-text-tertiary transition-transform duration-200', open && 'rotate-90', className)}
67
+ className={clsx(
68
+ 'shrink-0 text-theme-text-tertiary transition-transform duration-200 motion-reduce:transition-none',
69
+ open && 'rotate-90',
70
+ className,
71
+ )}
57
72
  />
58
73
  )
59
74
  }
@@ -0,0 +1,37 @@
1
+ import { type ReactNode } from 'react'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import { ConfirmDialog } from './ConfirmDialog'
5
+
6
+ vi.mock('./DialogPortal', () => ({
7
+ DialogPortal: ({ children }: { children: ReactNode }) => <>{children}</>,
8
+ }))
9
+
10
+ const props = {
11
+ open: true,
12
+ onClose: () => {},
13
+ onConfirm: () => {},
14
+ title: 'Share this investigation?',
15
+ message: 'Organization members can read and continue this investigation.',
16
+ confirmLabel: 'Share with organization',
17
+ }
18
+
19
+ describe('ConfirmDialog warning disclosure', () => {
20
+ it('can omit redundant boilerplate without removing the actual disclosure or actions', () => {
21
+ const html = renderToStaticMarkup(<ConfirmDialog {...props} variant="warning" showWarning={false} />)
22
+ expect(html).toContain(props.message)
23
+ expect(html).toContain(props.confirmLabel)
24
+ expect(html).toContain('Cancel')
25
+ expect(html).not.toContain('Please confirm you want to proceed')
26
+ })
27
+
28
+ it('preserves warning boilerplate by default for existing callers', () => {
29
+ expect(renderToStaticMarkup(<ConfirmDialog {...props} variant="warning" />))
30
+ .toContain('Please confirm you want to proceed with this action.')
31
+ })
32
+
33
+ it('preserves the irreversible-action warning for destructive callers', () => {
34
+ expect(renderToStaticMarkup(<ConfirmDialog {...props} />))
35
+ .toContain('This action cannot be undone.')
36
+ })
37
+ })
@@ -18,6 +18,7 @@ interface ConfirmDialogProps {
18
18
  isLoading?: boolean
19
19
  isClosable?: boolean // Allow closing even when isLoading (e.g., for long-running ops the user can dismiss)
20
20
  confirmDisabled?: boolean // Block the confirm action while custom content is invalid (e.g., bad YAML)
21
+ showWarning?: boolean
21
22
  className?: string
22
23
  children?: ReactNode // Optional custom content (e.g., checkboxes)
23
24
  }
@@ -35,6 +36,7 @@ export function ConfirmDialog({
35
36
  isLoading = false,
36
37
  isClosable = false,
37
38
  confirmDisabled = false,
39
+ showWarning = true,
38
40
  className,
39
41
  children,
40
42
  }: ConfirmDialogProps) {
@@ -84,7 +86,7 @@ export function ConfirmDialog({
84
86
  )}
85
87
 
86
88
  {/* Warning message — hidden once the action is in progress */}
87
- {!isLoading && !children && (
89
+ {showWarning && !isLoading && !children && (
88
90
  <div className="p-4">
89
91
  <div
90
92
  className={clsx(
@@ -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
@@ -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>
@@ -289,7 +291,7 @@ function isConditionHealthy(cond: { type?: string; status?: string }): boolean {
289
291
 
290
292
  export type ConditionTone = 'ok' | 'warning' | 'fail' | 'unknown'
291
293
 
292
- function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
294
+ export function defaultConditionTone(cond: { type?: string; status?: string }): ConditionTone {
293
295
  if (cond.status !== 'True' && cond.status !== 'False') return 'unknown'
294
296
  return isConditionHealthy(cond) ? 'ok' : 'fail'
295
297
  }
@@ -728,10 +728,11 @@ export function WorkloadView({
728
728
  // prominent "Diagnose" on a problem, a quiet icon when fine. Rendered via the host
729
729
  // slot (DiagnoseCustomization); standalone Radar injects it, Hub overrides it.
730
730
  const renderDiagnose = actionsBarProps?.renderDiagnose as
731
- | ((ctx: { kind: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
731
+ | ((ctx: { kind: string; group?: string; namespace: string; name: string; health?: DiagnoseHealthHint }) => ReactNode)
732
732
  | undefined
733
733
  const diagnoseAction = renderDiagnose?.({
734
734
  kind: apiKind,
735
+ group,
735
736
  namespace,
736
737
  name,
737
738
  health: diagnoseHealthHint(apiKind, resource),
@@ -880,8 +881,15 @@ export function WorkloadView({
880
881
  {/* Success animation overlay */}
881
882
  {saveSuccess && <SaveSuccessAnimation />}
882
883
 
883
- {/* Content — viewTransitionName scopes View Transitions API cross-fade to this element */}
884
- <div className="flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
884
+ {/* Content — viewTransitionName scopes View Transitions API cross-fade to this element.
885
+ `relative` is the backstop for absolutely-positioned descendants (Tailwind's
886
+ `sr-only` is position:absolute). The drawer frame keeps overflow visible at idle so
887
+ popovers aren't clipped, and the app's content column clips only horizontally, so one
888
+ that resolves its containing block above this scroller escapes into the DOCUMENT's
889
+ scrollable overflow — a second, page-level scrollbar. Anchoring here confines the
890
+ damage to this scroll body; ui/Collapse.tsx keeps them from escaping in the first
891
+ place. */}
892
+ <div className="relative flex-1 overflow-y-auto" style={{ viewTransitionName: 'drawer-content' }}>
885
893
  {!resource ? (
886
894
  // Fill the drawer body so the loading logo centers in it, not in a
887
895
  // 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
  // ============================================================================