@skyhook-io/k8s-ui 1.17.1 → 1.18.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 (107) hide show
  1. package/README.md +13 -0
  2. package/package.json +4 -4
  3. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  4. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  5. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  6. package/src/components/gitops/GitOpsGraphFilterRail.tsx +2 -2
  7. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  8. package/src/components/gitops/GitOpsTableView.tsx +33 -4
  9. package/src/components/gitops/detail-helpers.test.ts +58 -13
  10. package/src/components/gitops/detail-helpers.ts +37 -6
  11. package/src/components/gitops/index.ts +1 -0
  12. package/src/components/gitops/insights/ArgoResourceDiff.test.tsx +27 -0
  13. package/src/components/gitops/insights/GitOpsInsightViews.tsx +16 -10
  14. package/src/components/gitops/insights/insights-helpers.test.ts +18 -0
  15. package/src/components/gitops/insights/insights-helpers.ts +7 -0
  16. package/src/components/gitops/tree/GitOpsTreeGraph.test.ts +73 -9
  17. package/src/components/gitops/tree/GitOpsTreeGraph.tsx +45 -28
  18. package/src/components/gitops/tree/tree-helpers.test.ts +6 -0
  19. package/src/components/gitops/tree/tree-helpers.ts +5 -1
  20. package/src/components/issues/IssuesView.tsx +18 -10
  21. package/src/components/issues/ResourceIssuesSection.tsx +5 -2
  22. package/src/components/issues/diagnostic.ts +2 -0
  23. package/src/components/issues/index.ts +1 -1
  24. package/src/components/issues/issues.test.ts +26 -0
  25. package/src/components/issues/severity.ts +9 -2
  26. package/src/components/issues/types.ts +32 -4
  27. package/src/components/logs/LogCore.theme.test.tsx +126 -1
  28. package/src/components/logs/LogCore.tsx +159 -40
  29. package/src/components/logs/LogsViewer.test.tsx +183 -60
  30. package/src/components/logs/LogsViewer.tsx +49 -12
  31. package/src/components/logs/StructuredLogLine.tsx +1 -1
  32. package/src/components/logs/WorkloadLogsViewer.tsx +7 -1
  33. package/src/components/logs/useLogBuffer.reading.test.tsx +149 -0
  34. package/src/components/logs/useLogBuffer.ts +48 -18
  35. package/src/components/logs/useLogSearch.ts +3 -1
  36. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  37. package/src/components/namespace-switcher/NamespacePicker.tsx +42 -8
  38. package/src/components/resources/ColumnPickerList.test.tsx +46 -0
  39. package/src/components/resources/ColumnPickerList.tsx +174 -0
  40. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  41. package/src/components/resources/ResourcesSidebar.tsx +36 -21
  42. package/src/components/resources/ResourcesView.kind-view.test.tsx +149 -2
  43. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  44. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  45. package/src/components/resources/ResourcesView.tsx +198 -93
  46. package/src/components/resources/column-order.test.ts +104 -0
  47. package/src/components/resources/column-order.ts +64 -0
  48. package/src/components/resources/renderers/ArgoApplicationRenderer.test.tsx +15 -0
  49. package/src/components/resources/renderers/ArgoApplicationRenderer.tsx +4 -1
  50. package/src/components/resources/renderers/CronJobRenderer.test.tsx +31 -0
  51. package/src/components/resources/renderers/CronJobRenderer.tsx +28 -20
  52. package/src/components/resources/renderers/FluxHelmReleaseRenderer.tsx +22 -9
  53. package/src/components/resources/renderers/PodRenderer.sidecar-terminal.test.tsx +200 -0
  54. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  55. package/src/components/resources/renderers/PodRenderer.tsx +72 -20
  56. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  57. package/src/components/resources/resource-utils-trivy.ts +122 -0
  58. package/src/components/resources/resource-utils.ts +15 -2
  59. package/src/components/resources/summarize-scheduler-message.test.ts +1 -0
  60. package/src/components/shared/DetailShell.tsx +9 -7
  61. package/src/components/shared/DrainPlanDialog.test.tsx +7 -0
  62. package/src/components/shared/DrainPlanDialog.tsx +6 -2
  63. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  64. package/src/components/shared/ManagedByChip.tsx +24 -4
  65. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  66. package/src/components/shared/ResourceActionsBar.tsx +12 -6
  67. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  68. package/src/components/shared/ResourceRendererDispatch.tsx +48 -31
  69. package/src/components/timeline/DiffViewer.test.tsx +69 -0
  70. package/src/components/timeline/DiffViewer.tsx +113 -4
  71. package/src/components/timeline/TimelineList.tsx +5 -1
  72. package/src/components/timeline/shared.test.tsx +48 -0
  73. package/src/components/timeline/shared.tsx +14 -19
  74. package/src/components/ui/FetchResult.test.tsx +19 -2
  75. package/src/components/ui/FetchResult.tsx +13 -1
  76. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  77. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  78. package/src/components/ui/PageHeader.tsx +6 -4
  79. package/src/components/ui/Toast.tsx +48 -30
  80. package/src/components/ui/drawer-components.test.tsx +253 -0
  81. package/src/components/ui/drawer-components.tsx +315 -11
  82. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  83. package/src/components/ui/index.ts +1 -1
  84. package/src/components/workload/WorkloadView.relationships.test.tsx +33 -0
  85. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  86. package/src/components/workload/WorkloadView.tsx +68 -14
  87. package/src/hooks/index.ts +2 -0
  88. package/src/hooks/useMediaQuery.ts +21 -0
  89. package/src/hooks/usePhoneLayout.ts +12 -0
  90. package/src/theme/tailwind-theme.css +5 -0
  91. package/src/types/core.ts +91 -3
  92. package/src/types/gitops-flux-inventory.test.ts +24 -0
  93. package/src/types/gitops-insights.ts +1 -0
  94. package/src/types/gitops-tree.ts +2 -0
  95. package/src/types/gitops.ts +5 -5
  96. package/src/types/index.ts +3 -0
  97. package/src/types/read-coverage.ts +65 -0
  98. package/src/types/resource-coverage.ts +40 -0
  99. package/src/utils/format.test.ts +7 -0
  100. package/src/utils/format.ts +6 -1
  101. package/src/utils/gitops-owner.test.ts +42 -1
  102. package/src/utils/gitops-owner.ts +22 -0
  103. package/src/utils/index.ts +2 -0
  104. package/src/utils/read-actions.test.ts +22 -0
  105. package/src/utils/read-actions.ts +56 -0
  106. package/src/utils/resource-coverage.test.ts +121 -0
  107. package/src/utils/resource-coverage.ts +117 -0
@@ -19,6 +19,7 @@ export interface DrainPlanPod {
19
19
  pdb?: string
20
20
  pdbChecked: boolean
21
21
  terminating?: boolean
22
+ expectedSkip?: boolean
22
23
  }
23
24
 
24
25
  export interface DrainPlan {
@@ -95,7 +96,8 @@ const OUTCOME_LABEL: Record<DrainOutcome, string> = {
95
96
 
96
97
  const ESTIMATE_CAVEAT =
97
98
  'An estimate, not a guarantee: the drain re-lists live state when it runs, and pods, budgets and permissions can change until then. ' +
98
- 'Evictions a budget refuses are retried until the drain deadline (60 seconds, shared by all evictions); a pod covered by more than one budget is refused outright.'
99
+ 'Drain normally waits up to 2 minutes for evictions and pod deletion, retrying when a disruption budget temporarily blocks eviction. ' +
100
+ 'Older Radar versions may return before pods are deleted. A pod covered by more than one budget is refused outright.'
99
101
 
100
102
  interface DrainPlanContentProps {
101
103
  nodeName: string
@@ -217,7 +219,9 @@ export function DrainPlanContent({
217
219
  {current.pods.map((p) => (
218
220
  <tr key={`${p.namespace}/${p.name}`} className="border-b border-theme-border last:border-b-0 align-top">
219
221
  <td className="px-2 py-1.5 whitespace-nowrap">
220
- <Badge severity={OUTCOME_SEVERITY[p.outcome]} size="sm">{OUTCOME_LABEL[p.outcome]}</Badge>
222
+ <Badge severity={p.outcome === 'skip' && p.expectedSkip === false ? 'warning' : OUTCOME_SEVERITY[p.outcome]} size="sm">
223
+ {p.outcome === 'skip' && p.expectedSkip !== undefined ? p.expectedSkip ? 'left by design' : 'requires attention' : OUTCOME_LABEL[p.outcome]}
224
+ </Badge>
221
225
  </td>
222
226
  <td className="px-2 py-1.5 font-mono whitespace-nowrap text-theme-text-primary">
223
227
  {p.namespace}/{p.name}
@@ -0,0 +1,22 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+
4
+ import { ManagedByChip } from './ManagedByChip'
5
+
6
+ const owner = { tool: 'argocd' as const, kind: 'applications' as const, namespace: 'argocd', name: 'billing' }
7
+
8
+ describe('ManagedByChip', () => {
9
+ it('says Managed by for a verified owner its status confirms', () => {
10
+ const html = renderToString(<ManagedByChip owner={owner} verified listing={{ state: 'confirmed' }} />)
11
+ expect(html).toContain('Managed by')
12
+ })
13
+
14
+ it('says Tracked by, with a warning, for an owner whose status does not list the resource while another does', () => {
15
+ const html = renderToString(
16
+ <ManagedByChip owner={owner} verified listing={{ state: 'conflict', listedBy: [{ tool: 'argocd', kind: 'applications', namespace: 'argocd', name: 'hub' }] }} />,
17
+ )
18
+ expect(html).toContain('Tracked by')
19
+ expect(html).not.toContain('Managed by')
20
+ expect(html).toMatch(/rounded-full[^"]*bg-amber/)
21
+ })
22
+ })
@@ -1,6 +1,6 @@
1
1
  import { GitBranch, Package } from 'lucide-react'
2
2
  import { clsx } from 'clsx'
3
- import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
3
+ import type { GitOpsOwnerRef, ManagerListing } from '../../utils/gitops-owner'
4
4
  import type { GitOpsStatus } from '../../types/gitops'
5
5
  import { StatusDot, type StatusTone } from '../ui/status-tone'
6
6
  import { Tooltip } from '../ui/Tooltip'
@@ -11,7 +11,10 @@ export type HelmOwnerRef = {
11
11
  }
12
12
 
13
13
  // ManagedByChip renders the "Managed by <ArgoCD/FluxCD app>" affordance for
14
- // resources detected (via labels/annotations) to be GitOps-managed. The chip
14
+ // resources detected (via labels/annotations) to be GitOps-managed. `listing`
15
+ // compares that claim with the managers whose status lists the resource: a
16
+ // manager that doesn't list it while another does is shown as "Tracked by"
17
+ // with a warning, since the claim is all that names it. The chip
15
18
  // is clickable when the host wires `onOpen`; integrators that don't surface
16
19
  // a GitOps tab can omit the handler and the chip degrades to a passive badge
17
20
  // so the relationship is still visible.
@@ -26,6 +29,7 @@ export function ManagedByChip({
26
29
  verified = true,
27
30
  pending = false,
28
31
  source,
32
+ listing,
29
33
  variant = 'inline',
30
34
  }: {
31
35
  owner: GitOpsOwnerRef
@@ -34,13 +38,15 @@ export function ManagedByChip({
34
38
  verified?: boolean
35
39
  pending?: boolean
36
40
  source?: string | null
41
+ listing?: ManagerListing
37
42
  variant?: 'inline' | 'block'
38
43
  }) {
39
44
  const toolLabel = owner.tool === 'argocd' ? 'ArgoCD' : 'FluxCD'
40
45
  const ownerKindLabel = gitOpsOwnerKindLabel(owner)
41
46
  const label = owner.namespace ? `${owner.namespace}/${owner.name}` : owner.name
42
47
  const statusLabel = status ? gitOpsStatusLabel(status) : null
43
- const prefix = pending ? 'Resolving' : verified ? 'Managed by' : 'Tracked by'
48
+ const conflict = listing?.state === 'conflict' ? listing.listedBy : null
49
+ const prefix = pending ? 'Resolving' : verified && !conflict ? 'Managed by' : 'Tracked by'
44
50
  const tooltipContent = (
45
51
  <span className="flex flex-col gap-1">
46
52
  <span>
@@ -48,10 +54,23 @@ export function ManagedByChip({
48
54
  <>
49
55
  Looking up matching {ownerKindLabel} <TooltipCode>{label}</TooltipCode> in this cluster.
50
56
  </>
57
+ ) : conflict ? (
58
+ <>
59
+ This resource's {toolLabel} tracking metadata names <TooltipCode>{label}</TooltipCode>, but that {ownerKindLabel}'s status doesn't list it.{' '}
60
+ {conflict.length === 1 ? 'The status of ' : 'The statuses of '}
61
+ {conflict.map((m, i) => (
62
+ <span key={`${m.namespace}/${m.name}`}>
63
+ {i > 0 && ', '}
64
+ <TooltipCode>{m.namespace ? `${m.namespace}/${m.name}` : m.name}</TooltipCode>
65
+ </span>
66
+ ))}{' '}
67
+ {conflict.length === 1 ? 'does.' : 'do.'}
68
+ </>
51
69
  ) : verified ? (
52
70
  <>
53
71
  Managed by {toolLabel} · <TooltipCode>{label}</TooltipCode>
54
72
  {statusLabel && <> · {statusLabel}{status?.message ? `: ${status.message}` : ''}</>}
73
+ {listing?.state === 'confirmed' && <>. Its status lists this resource too.</>}
55
74
  </>
56
75
  ) : (
57
76
  <>
@@ -80,6 +99,7 @@ export function ManagedByChip({
80
99
  )}
81
100
  >
82
101
  <GitBranch className="h-3 w-3 shrink-0" />
102
+ {conflict && <StatusDot tone="degraded" size="xs" />}
83
103
  <span className="shrink-0 text-theme-text-tertiary">{prefix}</span>
84
104
  <span className="truncate max-w-[180px]">{label}</span>
85
105
  {statusLabel && (
@@ -141,7 +161,7 @@ export function HelmManagedByChip({
141
161
  )
142
162
  }
143
163
 
144
- function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
164
+ export function gitOpsOwnerKindLabel(owner: GitOpsOwnerRef): string {
145
165
  if (owner.tool === 'argocd') return 'Application'
146
166
  if (owner.kind === 'helmreleases') return 'HelmRelease'
147
167
  if (owner.kind === 'kustomizations') return 'Kustomization'
@@ -0,0 +1,64 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { ResourceActionsBar } from './ResourceActionsBar'
4
+
5
+ const noop = () => {}
6
+
7
+ function actions(kind: string, apiVersion: string) {
8
+ return renderToString(
9
+ <ResourceActionsBar
10
+ resource={{ kind, name: 'example', namespace: 'test' }}
11
+ data={{ apiVersion, metadata: { name: 'example', namespace: 'test' } }}
12
+ onFluxReconcile={noop}
13
+ onFluxSuspend={noop}
14
+ onFluxResume={noop}
15
+ onArgoSync={noop}
16
+ onArgoRefresh={noop}
17
+ onArgoSuspend={noop}
18
+ onArgoResume={noop}
19
+ canPortForward
20
+ renderPortForward={() => <button>Port forward</button>}
21
+ />,
22
+ )
23
+ }
24
+
25
+ describe('resource actions use the loaded API group', () => {
26
+ it.each(['app.k8s.io/v1beta1', 'notargoproj.io/v1alpha1', ''])('does not offer Argo actions for Application in %s', (apiVersion) => {
27
+ const html = actions('applications', apiVersion)
28
+ expect(html).not.toContain('>Sync<')
29
+ expect(html).not.toContain('>Refresh<')
30
+ expect(html).not.toContain('Enable Auto-Sync')
31
+ })
32
+
33
+ it('retains Argo actions for its exact Application group', () => {
34
+ const html = actions('applications', 'argoproj.io/v1alpha1')
35
+ expect(html).toContain('>Sync<')
36
+ expect(html).toContain('>Refresh<')
37
+ expect(html).toContain('Enable Auto-Sync')
38
+ })
39
+
40
+ it.each(['serving.knative.dev/v1', 'vendor.serving.knative.dev/v1', 'vendor.io/v1', ''])('does not port-forward a non-core Service in %s', (apiVersion) => {
41
+ expect(actions('services', apiVersion)).not.toContain('Port forward')
42
+ })
43
+
44
+ it('retains port forwarding for core Service', () => {
45
+ expect(actions('services', 'v1')).toContain('Port forward')
46
+ })
47
+ it.each([
48
+ ['gitrepositories', 'source.toolkit.fluxcd.io'],
49
+ ['ocirepositories', 'source.toolkit.fluxcd.io'],
50
+ ['helmrepositories', 'source.toolkit.fluxcd.io'],
51
+ ['kustomizations', 'kustomize.toolkit.fluxcd.io'],
52
+ ['helmreleases', 'helm.toolkit.fluxcd.io'],
53
+ ['alerts', 'notification.toolkit.fluxcd.io'],
54
+ ])('only offers Flux actions for %s in its exact group', (kind, group) => {
55
+ expect(actions(kind, `${group}/v1`)).toContain('>Reconcile<')
56
+ for (const apiVersion of ['', 'vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
57
+ const html = actions(kind, apiVersion)
58
+ expect(html).not.toContain('>Reconcile<')
59
+ expect(html).not.toContain('>Suspend<')
60
+ expect(html).not.toContain('>Resume<')
61
+ }
62
+ })
63
+
64
+ })
@@ -28,10 +28,13 @@ import { DialogPortal } from '../ui/DialogPortal'
28
28
  import type { SelectedResource, WorkloadRevision } from '../../types'
29
29
  import type { RadarUpgradeRequirement } from '../../types/fetch-error'
30
30
  import { displayKindName } from '../ui/drawer-components'
31
- import { getDefaultContainerName } from '../resources/resource-utils'
31
+ import { getDefaultContainerName, getExecContainerNames } from '../resources/resource-utils'
32
32
  import { SetImageDialog, type SetImageOwnership } from './SetImageDialog'
33
33
  import { DrainPlanDialog, DEFAULT_DRAIN_DIALOG_OPTIONS, type DrainDialogOptions, type DrainPlan } from './DrainPlanDialog'
34
34
  import type { WorkloadImageInventory, WorkloadImageUpdate } from '../../types/core'
35
+ import { getGitOpsTool } from '../gitops/detail-helpers'
36
+ import { apiVersionToGroup } from '../../utils/navigation'
37
+ import { isApiGroup } from '../resources/resource-utils-cnpg'
35
38
  import { isArgoRolloutResource } from '../../utils/workload-rollout'
36
39
  import { isCoreBatchJob } from '../../utils/api-resources'
37
40
  import { formatRelativeAgeTime } from '../../utils/format'
@@ -40,6 +43,8 @@ import { formatRelativeAgeTime } from '../../utils/format'
40
43
  // ACTIONS BAR - Interactive buttons that change based on resource kind
41
44
  // ============================================================================
42
45
 
46
+ const FLUX_ACTION_KINDS = new Set(['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'])
47
+
43
48
  interface ResourceActionsBarProps {
44
49
  resource: SelectedResource
45
50
  leadingActions?: React.ReactNode
@@ -187,6 +192,7 @@ export function ResourceActionsBar({
187
192
  onPlanDrain, onPlanDrainReset, drainPlan, isPlanningDrain, drainPlanError, drainPlanUpgrade,
188
193
  }: ResourceActionsBarProps) {
189
194
  const kind = resource.kind.toLowerCase()
195
+ const isFluxAction = FLUX_ACTION_KINDS.has(kind) && getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
190
196
  const coreBatchJob = isCoreBatchJob(kind, resource.group)
191
197
  const supportsWorkloadActions = ['deployments', 'statefulsets', 'daemonsets'].includes(kind) ||
192
198
  (kind === 'rollouts' && isArgoRolloutResource(data))
@@ -254,7 +260,7 @@ export function ResourceActionsBar({
254
260
  namespace: resource.namespace,
255
261
  podName: resource.name,
256
262
  containerName: getDefaultContainerName(data) || containers[0],
257
- containers,
263
+ containers: getExecContainerNames(data),
258
264
  })
259
265
  }
260
266
  }
@@ -370,7 +376,7 @@ export function ResourceActionsBar({
370
376
  onUncordonNode && (
371
377
  <button
372
378
  onClick={() => onUncordonNode({ name: resource.name })}
373
- disabled={isUncordoningNode}
379
+ disabled={isUncordoningNode || isDrainingNode}
374
380
  className="flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-white bg-emerald-600 hover:bg-emerald-700 rounded-lg transition-colors disabled:opacity-50"
375
381
  >
376
382
  <Play className={`w-3.5 h-3.5 ${isUncordoningNode ? 'animate-pulse' : ''}`} />
@@ -406,7 +412,7 @@ export function ResourceActionsBar({
406
412
  )}
407
413
 
408
414
  {/* Service actions */}
409
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && canPortForward && resource.namespace && resource.name && renderPortForward && (
415
+ {kind === 'services' && data?.apiVersion === 'v1' && canPortForward && resource.namespace && resource.name && renderPortForward && (
410
416
  renderPortForward({
411
417
  type: 'service',
412
418
  namespace: resource.namespace,
@@ -516,7 +522,7 @@ export function ResourceActionsBar({
516
522
  )}
517
523
 
518
524
  {/* FluxCD actions */}
519
- {['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind) && (
525
+ {isFluxAction && (
520
526
  <FluxActions
521
527
  resource={resource}
522
528
  data={data}
@@ -532,7 +538,7 @@ export function ResourceActionsBar({
532
538
  )}
533
539
 
534
540
  {/* ArgoCD actions */}
535
- {kind === 'applications' && (
541
+ {kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && (
536
542
  <ArgoActions
537
543
  resource={resource}
538
544
  data={data}
@@ -1087,3 +1087,52 @@ it('uses RayJob native detail only for the supported exact API', () => {
1087
1087
  if (apiVersion !== 'ray.io/v1') { expect(html).toContain('native-fallback'); expect(getResourceStatus('rayjobs', data)).toBeNull() }
1088
1088
  }
1089
1089
  })
1090
+
1091
+ describe('API group identity', () => {
1092
+ it.each([
1093
+ ['applications', 'app.k8s.io/v1beta1', 'Application'],
1094
+ ['applications', 'notargoproj.io/v1alpha1', 'Application'],
1095
+ ['services', 'vendor.serving.knative.dev/v1', 'Service'],
1096
+ ['ingresses', 'vendor.networking.internal.knative.dev/v1alpha1', 'Ingress'],
1097
+ ['certificates', 'vendor.networking.internal.knative.dev/v1alpha1', 'Certificate'],
1098
+ ['gateways', 'vendor.networking.istio.io/v1', 'Gateway'],
1099
+ ])('keeps %s in %s generic', (plural, apiVersion, kind) => {
1100
+ const data = { apiVersion, kind, metadata: { name: 'foreign', namespace: 'test' }, spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' } }
1101
+ expect(renderKind(plural, data, 'test')).toContain('custom-value')
1102
+ expect(getResourceStatus(plural, data)?.text).toBe('VendorReady')
1103
+ })
1104
+
1105
+ it('retains Argo CD status for its exact Application group', () => {
1106
+ const data = { apiVersion: 'argoproj.io/v1alpha1', kind: 'Application', metadata: { name: 'real' }, status: { sync: { status: 'Synced' }, health: { status: 'Healthy' } } }
1107
+ expect(renderKind('applications', data)).toContain('Synced')
1108
+ expect(getResourceStatus('applications', data)?.text).toBe('Healthy')
1109
+ })
1110
+
1111
+ it('retains Knative Service status and core Service presentation', () => {
1112
+ expect(getResourceStatus('services', { apiVersion: 'serving.knative.dev/v1', status: { conditions: [{ type: 'Ready', status: 'True' }] } })?.text).toBe('Ready')
1113
+ expect(renderKind('services', { apiVersion: 'v1', metadata: { name: 'core' }, spec: { type: 'ClusterIP', clusterIP: '10.0.0.1' } })).toContain('10.0.0.1')
1114
+ })
1115
+ })
1116
+
1117
+ describe('Flux kind collisions', () => {
1118
+ it.each([
1119
+ ['gitrepositories', 'source.toolkit.fluxcd.io'],
1120
+ ['ocirepositories', 'source.toolkit.fluxcd.io'],
1121
+ ['helmrepositories', 'source.toolkit.fluxcd.io'],
1122
+ ['kustomizations', 'kustomize.toolkit.fluxcd.io'],
1123
+ ['helmreleases', 'helm.toolkit.fluxcd.io'],
1124
+ ['alerts', 'notification.toolkit.fluxcd.io'],
1125
+ ])('keeps foreign %s generic and preserves its real Flux renderer', (kind, group) => {
1126
+ const resource = {
1127
+ apiVersion: `${group}/v1`, metadata: { name: 'example', namespace: 'test' },
1128
+ spec: { vendorSetting: 'custom-value' }, status: { phase: 'VendorReady' },
1129
+ }
1130
+ expect(renderKind(kind, resource, 'test')).not.toContain('custom-value')
1131
+ expect(getResourceStatus(kind, resource)?.text).not.toBe('VendorReady')
1132
+ for (const apiVersion of ['vendor.io/v1', `foreign.${group}/v1`, 'argoproj.io/v1alpha1']) {
1133
+ const foreign = { ...resource, apiVersion }
1134
+ expect(renderKind(kind, foreign, 'test')).toContain('custom-value')
1135
+ expect(getResourceStatus(kind, foreign)?.text).toBe('VendorReady')
1136
+ }
1137
+ })
1138
+ })
@@ -1,3 +1,5 @@
1
+ import { getGitOpsTool } from '../gitops/detail-helpers'
2
+ import { apiVersionToGroup } from '../../utils/navigation'
1
3
  import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
2
4
  import { clsx } from 'clsx'
3
5
  import { SEVERITY_BADGE, HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
@@ -314,6 +316,7 @@ import type { ScalerDiagnosis } from '../resources/renderers/WorkloadRenderer'
314
316
  * When an override is not provided, the base (shared) renderer is used.
315
317
  */
316
318
  export interface RendererOverrides {
319
+ FluxHelmReleaseRenderer?: React.ComponentType<React.ComponentProps<typeof FluxHelmReleaseRenderer>>
317
320
  JobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
318
321
  JobSetRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
319
322
  RayJobRenderer?: React.ComponentType<{ data: any; onNavigate?: (ref: ResourceRef) => void }>
@@ -551,6 +554,8 @@ interface ResourceRendererDispatchProps {
551
554
  resource: SelectedResource
552
555
  data: any
553
556
  relationships?: Relationships
557
+ /** See RelatedResourcesSection. */
558
+ trackedBy?: string
554
559
  certificateInfo?: SecretCertificateInfo
555
560
  hpaDiagnosis?: HPADiagnosis
556
561
  scalerDiagnostics?: ScalerDiagnosis[]
@@ -597,6 +602,7 @@ export function ResourceRendererDispatch({
597
602
  resource,
598
603
  data,
599
604
  relationships,
605
+ trackedBy,
600
606
  certificateInfo,
601
607
  hpaDiagnosis,
602
608
  scalerDiagnostics,
@@ -700,8 +706,11 @@ export function ResourceRendererDispatch({
700
706
  // foreign CRD render a BLANK drawer rather than the generic one.
701
707
  // CAPI's `machines` and `machinesets` need the same protection because another
702
708
  // API group can publish either plural without inheriting CAPI presentation.
709
+ const isFluxKind = ['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind)
710
+ const isFluxResource = getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
703
711
  const isGroupGatedKind =
704
- kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
712
+ isFluxKind || kind === 'applications' || kind === 'services' || kind === 'ingresses' || kind === 'certificates' || kind === 'gateways'
713
+ || kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
705
714
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
706
715
  || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'databaseroles' || kind === 'jobsets'
707
716
  || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
@@ -709,7 +718,13 @@ export function ResourceRendererDispatch({
709
718
  || kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
710
719
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
711
720
  const groupGatedMatched =
712
- (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
721
+ (isFluxKind && isFluxResource)
722
+ || (kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io'))
723
+ || (kind === 'services' && (data?.apiVersion === 'v1' || isApiGroup(data?.apiVersion, 'serving.knative.dev')))
724
+ || (kind === 'ingresses' && ['networking.k8s.io', 'extensions', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
725
+ || (kind === 'certificates' && ['cert-manager.io', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
726
+ || (kind === 'gateways' && ['gateway.networking.k8s.io', 'networking.istio.io'].some(group => isApiGroup(data?.apiVersion, group)))
727
+ || (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
713
728
  || (kind === 'backups' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'velero.io')))
714
729
  || ((kind === 'scheduledbackups' || kind === 'poolers') && isCNPGApiVersion)
715
730
  || (kind === 'objectstores' && isApiGroup(data?.apiVersion, 'barmancloud.cnpg.io'))
@@ -755,6 +770,7 @@ export function ResourceRendererDispatch({
755
770
  const isKnownKind = KNOWN_KINDS.has(kind) || isCrossplaneMR || isCrossplaneClaim || isCrossplaneXR
756
771
 
757
772
  const JobComp = rendererOverrides?.JobRenderer ?? JobRenderer
773
+ const FluxHelmReleaseComp = rendererOverrides?.FluxHelmReleaseRenderer ?? FluxHelmReleaseRenderer
758
774
  const JobSetComp = rendererOverrides?.JobSetRenderer ?? JobSetRenderer
759
775
  const RayJobComp = rendererOverrides?.RayJobRenderer ?? RayJobRenderer
760
776
  const RayClusterComp = rendererOverrides?.RayClusterRenderer ?? RayClusterRenderer
@@ -797,7 +813,7 @@ export function ResourceRendererDispatch({
797
813
 
798
814
  const sidebarContent = showCommonSections && (
799
815
  <>
800
- <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
816
+ <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} kind={kind} trackedBy={trackedBy} />
801
817
  {kind !== 'events' && <EventsSection fullscreen={!!renderSidebar} events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
802
818
  <LabelsSection data={data} />
803
819
  <AnnotationsSection data={data} />
@@ -822,9 +838,9 @@ export function ResourceRendererDispatch({
822
838
  />
823
839
  )}
824
840
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
825
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
841
+ {kind === 'services' && data?.apiVersion === 'v1' && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
826
842
  {kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
827
- {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
843
+ {kind === 'ingresses' && (isApiGroup(data?.apiVersion, 'networking.k8s.io') || isApiGroup(data?.apiVersion, 'extensions')) && <IngressRenderer data={data} onNavigate={onNavigate} />}
828
844
  {kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
829
845
  {kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
830
846
  {kind === 'jobs' && !nonCoreJobFallthrough && <JobComp data={data} onNavigate={onNavigate} />}
@@ -846,7 +862,7 @@ export function ResourceRendererDispatch({
846
862
  {kind === 'analysisruns' && <AnalysisRunRenderer data={data} onNavigate={onNavigate} />}
847
863
  {(kind === 'analysistemplates' || kind === 'clusteranalysistemplates') && <AnalysisTemplateRenderer data={data} />}
848
864
  {kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ExperimentRenderer data={data} onNavigate={onNavigate} />}
849
- {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
865
+ {kind === 'certificates' && isApiGroup(data?.apiVersion, 'cert-manager.io') && <CertificateRenderer data={data} />}
850
866
  {kind === 'workflows' && <WorkflowRenderer data={data} onNavigate={onNavigate} />}
851
867
  {kind === 'persistentvolumes' && <PersistentVolumeRenderer data={data} onNavigate={onNavigate} />}
852
868
  {kind === 'storageclasses' && <StorageClassRenderer data={data} />}
@@ -855,7 +871,7 @@ export function ResourceRendererDispatch({
855
871
  {kind === 'issuers' && <IssuerRenderer data={data} />}
856
872
  {kind === 'orders' && <OrderRenderer data={data} />}
857
873
  {kind === 'challenges' && <ChallengeRenderer data={data} />}
858
- {kind === 'gateways' && (data.apiVersion?.includes('networking.istio.io') ? <IstioGatewayRenderer data={data} /> : <GatewayRenderer data={data} onNavigate={onNavigate} />)}
874
+ {kind === 'gateways' && groupGatedMatched && (isApiGroup(data.apiVersion, 'networking.istio.io') ? <IstioGatewayRenderer data={data} /> : <GatewayRenderer data={data} onNavigate={onNavigate} />)}
859
875
  {kind === 'gatewayclasses' && <GatewayClassRenderer data={data} />}
860
876
  {kind === 'httproutes' && <HTTPRouteRenderer data={data} onNavigate={onNavigate} />}
861
877
  {kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
@@ -875,13 +891,13 @@ export function ResourceRendererDispatch({
875
891
  {(kind === 'roles' || kind === 'clusterroles') && <RoleComp data={data} onNavigate={onNavigate} />}
876
892
  {(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingComp data={data} onNavigate={onNavigate} />}
877
893
  {kind === 'events' && <EventRenderer data={data} onNavigate={onNavigate} />}
878
- {kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
879
- {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
880
- {kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
881
- {kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
882
- {kind === 'helmreleases' && <FluxHelmReleaseRenderer data={data} onNavigate={onNavigate} />}
883
- {kind === 'alerts' && <AlertRenderer data={data} />}
884
- {kind === 'applications' && <ArgoApplicationRenderer data={data} />}
894
+ {kind === 'gitrepositories' && isFluxResource && <GitRepositoryRenderer data={data} />}
895
+ {kind === 'ocirepositories' && isFluxResource && <OCIRepositoryRenderer data={data} />}
896
+ {kind === 'helmrepositories' && isFluxResource && <HelmRepositoryRenderer data={data} />}
897
+ {kind === 'kustomizations' && isFluxResource && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
898
+ {kind === 'helmreleases' && isFluxResource && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
899
+ {kind === 'alerts' && isFluxResource && <AlertRenderer data={data} />}
900
+ {kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ArgoApplicationRenderer data={data} />}
885
901
  {kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
886
902
  {kind === 'nodeclaims' && <KarpenterNodeClaimRenderer data={data} onNavigate={onNavigate} />}
887
903
  {kind === 'ec2nodeclasses' && <KarpenterEC2NodeClassRenderer data={data} />}
@@ -987,14 +1003,14 @@ export function ResourceRendererDispatch({
987
1003
  {kind === 'leases' && <LeaseRenderer data={data} />}
988
1004
  {kind === 'limitranges' && <LimitRangeRenderer data={data} />}
989
1005
  {/* Knative Serving */}
990
- {(kind === 'services' && data?.apiVersion?.includes('serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
1006
+ {(kind === 'services' && isApiGroup(data?.apiVersion, 'serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
991
1007
  {kind === 'knativeservices' && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
992
1008
  {kind === 'knativeconfigurations' && <KnativeConfigurationRenderer data={data} onNavigate={onNavigate} />}
993
1009
  {kind === 'knativerevisions' && <KnativeRevisionRenderer data={data} onNavigate={onNavigate} />}
994
1010
  {kind === 'knativeroutes' && <KnativeRouteRenderer data={data} onNavigate={onNavigate} />}
995
- {(kind === 'ingresses' && data?.apiVersion?.includes('networking.internal.knative.dev')) && <KnativeIngressRenderer data={data} />}
1011
+ {(kind === 'ingresses' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeIngressRenderer data={data} />}
996
1012
  {kind === 'knativeingresses' && <KnativeIngressRenderer data={data} />}
997
- {(kind === 'certificates' && data?.apiVersion?.includes('networking.internal.knative.dev')) && <KnativeCertificateRenderer data={data} />}
1013
+ {(kind === 'certificates' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeCertificateRenderer data={data} />}
998
1014
  {kind === 'knativecertificates' && <KnativeCertificateRenderer data={data} />}
999
1015
  {kind === 'serverlessservices' && <ServerlessServiceRenderer data={data} onNavigate={onNavigate} />}
1000
1016
  {kind === 'domainmappings' && <DomainMappingRenderer data={data} onNavigate={onNavigate} />}
@@ -1099,11 +1115,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1099
1115
  }
1100
1116
  if (k === 'replicasets') return getWorkloadStatus(data, k)
1101
1117
  if (k === 'services') {
1102
- if (data.apiVersion?.includes('serving.knative.dev')) {
1118
+ if (isApiGroup(data.apiVersion, 'serving.knative.dev')) {
1103
1119
  const status = getKnativeConditionStatus(data)
1104
1120
  return { text: status.text, color: status.color }
1105
1121
  }
1106
- return getServiceStatus(data)
1122
+ if (data.apiVersion === 'v1') return getServiceStatus(data)
1107
1123
  }
1108
1124
  if (k === 'endpointslices') {
1109
1125
  const endpoints = data.endpoints || []
@@ -1176,11 +1192,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1176
1192
  if (k === 'workflows') return getWorkflowStatus(data)
1177
1193
  if (k === 'cronworkflows') return getCronWorkflowStatus(data)
1178
1194
  if (k === 'certificates') {
1179
- if (data.apiVersion?.includes('networking.internal.knative.dev')) {
1195
+ if (isApiGroup(data.apiVersion, 'networking.internal.knative.dev')) {
1180
1196
  const status = getKnativeConditionStatus(data)
1181
1197
  return { text: status.text, color: status.color }
1182
1198
  }
1183
- return getCertificateStatus(data)
1199
+ if (isApiGroup(data.apiVersion, 'cert-manager.io')) return getCertificateStatus(data)
1184
1200
  }
1185
1201
  if (k === 'persistentvolumes') return getPVStatus(data)
1186
1202
  if (k === 'certificaterequests') return getCertificateRequestStatus(data)
@@ -1189,20 +1205,21 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1189
1205
  if (k === 'orders') return getOrderState(data)
1190
1206
  if (k === 'challenges') return getChallengeState(data)
1191
1207
  if (k === 'gateways') {
1192
- if (data.apiVersion?.includes('networking.istio.io')) return getIstioGatewayStatus(data)
1193
- return getGatewayStatus(data)
1208
+ if (isApiGroup(data.apiVersion, 'networking.istio.io')) return getIstioGatewayStatus(data)
1209
+ if (isApiGroup(data.apiVersion, 'gateway.networking.k8s.io')) return getGatewayStatus(data)
1194
1210
  }
1195
1211
  if (k === 'gatewayclasses') return getGatewayClassStatus(data)
1196
1212
  if (k === 'httproutes' || k === 'grpcroutes' || k === 'tcproutes' || k === 'tlsroutes' || k === 'udproutes') return getRouteStatus(data)
1197
1213
  if (k === 'sealedsecrets') return getSealedSecretStatus(data)
1198
1214
  if (k === 'poddisruptionbudgets') return getPDBStatus(data)
1199
- if (k === 'gitrepositories') return getGitRepositoryStatus(data)
1200
- if (k === 'ocirepositories') return getOCIRepositoryStatus(data)
1201
- if (k === 'helmrepositories') return getHelmRepositoryStatus(data)
1202
- if (k === 'kustomizations') return getKustomizationStatus(data)
1203
- if (k === 'helmreleases') return getFluxHelmReleaseStatus(data)
1204
- if (k === 'alerts') return getFluxAlertStatus(data)
1205
- if (k === 'applications') return getArgoApplicationStatus(data)
1215
+ const isFluxResource = getGitOpsTool(k, apiVersionToGroup(data.apiVersion)) === 'flux'
1216
+ if (k === 'gitrepositories' && isFluxResource) return getGitRepositoryStatus(data)
1217
+ if (k === 'ocirepositories' && isFluxResource) return getOCIRepositoryStatus(data)
1218
+ if (k === 'helmrepositories' && isFluxResource) return getHelmRepositoryStatus(data)
1219
+ if (k === 'kustomizations' && isFluxResource) return getKustomizationStatus(data)
1220
+ if (k === 'helmreleases' && isFluxResource) return getFluxHelmReleaseStatus(data)
1221
+ if (k === 'alerts' && isFluxResource) return getFluxAlertStatus(data)
1222
+ if (k === 'applications' && isApiGroup(data.apiVersion, 'argoproj.io')) return getArgoApplicationStatus(data)
1206
1223
  if (k === 'nodepools') return getNodePoolStatus(data)
1207
1224
  if (k === 'nodeclaims') return getNodeClaimStatus(data)
1208
1225
  if (k === 'ec2nodeclasses') return getEC2NodeClassStatus(data)
@@ -1347,7 +1364,7 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1347
1364
  if (
1348
1365
  knativeConditionKinds.includes(k)
1349
1366
  || isKnativeSubscription
1350
- || (k === 'ingresses' && data.apiVersion?.includes('networking.internal.knative.dev'))
1367
+ || (k === 'ingresses' && isApiGroup(data.apiVersion, 'networking.internal.knative.dev'))
1351
1368
  ) {
1352
1369
  const status = getKnativeConditionStatus(data)
1353
1370
  return { text: status.text, color: status.color }
@@ -23,4 +23,73 @@ describe("DiffViewer", () => {
23
23
  expect(html).toContain("Old value:");
24
24
  expect(html).toContain("New value:");
25
25
  });
26
+
27
+ it("renders values kept out of the feed as a badge, not as struck-through text", () => {
28
+ const html = renderToStaticMarkup(
29
+ <DiffViewer
30
+ diff={{
31
+ summary: "pod template annotation checksum/config changed",
32
+ fields: [
33
+ { path: "spec.template.metadata.annotations[checksum/config]", oldValue: "(value hidden)", newValue: "(value hidden)" },
34
+ { path: "spec.template.metadata.annotations[example.com/new]", oldValue: null, newValue: "(set)" },
35
+ { path: "spec.template.metadata.annotations[example.com/old]", oldValue: "(set)", newValue: null },
36
+ { path: "spec.template.spec.affinity", oldValue: "changed", newValue: "changed" },
37
+ ],
38
+ }}
39
+ />,
40
+ );
41
+
42
+ expect(html).not.toContain("line-through");
43
+ expect(html).not.toContain("(value hidden)");
44
+ expect(html).not.toContain("(set)");
45
+ expect(html.match(/value hidden/g)).toHaveLength(4);
46
+ expect(html).toContain("Set");
47
+ expect(html).toContain("Removed");
48
+ });
49
+
50
+ it("names key changes instead of striking through an unchanged key list", () => {
51
+ const html = renderToStaticMarkup(
52
+ <DiffViewer
53
+ diff={{
54
+ summary: "data modified keys: [password]",
55
+ fields: [{ path: "data (modified keys)", oldValue: ["password"], newValue: ["password"] }],
56
+ }}
57
+ />,
58
+ );
59
+ expect(html).toContain("keys changed: <span class=\"font-mono\">password</span>");
60
+ expect(html).not.toContain("line-through");
61
+ expect(html).not.toContain("Old value:");
62
+ });
63
+
64
+ it("merges added, removed and modified key lists of one field into one row", () => {
65
+ const html = renderToStaticMarkup(
66
+ <DiffViewer
67
+ diff={{
68
+ summary: "",
69
+ fields: [
70
+ { path: "data (added keys)", oldValue: null, newValue: ["token"] },
71
+ { path: "data (removed keys)", oldValue: ["old"], newValue: null },
72
+ { path: "data (modified keys)", oldValue: ["a", "b"], newValue: ["a", "b"] },
73
+ { path: "stringData (added keys)", oldValue: null, newValue: ["extra"] },
74
+ { path: "type", oldValue: "Opaque", newValue: "kubernetes.io/tls" },
75
+ ],
76
+ }}
77
+ />,
78
+ );
79
+ expect(html.match(/>data</g)).toHaveLength(1);
80
+ expect(html).toMatch(/keys changed: <span[^>]*>a, b<\/span>.*added: <span[^>]*>token<\/span>.*removed: <span[^>]*>old<\/span>/);
81
+ expect(html).toContain(">stringData<");
82
+ expect(html).toContain("added: <span class=\"font-mono\">extra</span>");
83
+ // Ordinary fields keep the old→new rendering.
84
+ expect(html).toContain("Old value:");
85
+ expect(html).toContain("kubernetes.io/tls");
86
+ });
87
+
88
+ it("keeps the old→new rendering when a key-list path does not carry a list", () => {
89
+ const html = renderToStaticMarkup(
90
+ <DiffViewer diff={{ summary: "", fields: [{ path: "data (modified keys)", oldValue: "x", newValue: "y" }] }} />,
91
+ );
92
+ expect(html).toContain("Old value:");
93
+ expect(html).not.toContain("keys changed");
94
+ });
26
95
  });