@skyhook-io/k8s-ui 1.18.0 → 1.18.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 (128) hide show
  1. package/README.md +21 -0
  2. package/package.json +1 -1
  3. package/src/components/applications/ApplicationsView.tsx +8 -7
  4. package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
  5. package/src/components/compare/CompareResourcePicker.tsx +18 -4
  6. package/src/components/compare/ResourceCompareView.tsx +6 -2
  7. package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
  8. package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
  9. package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
  10. package/src/components/gitops/GitOpsTableView.tsx +46 -12
  11. package/src/components/gitops/detail-helpers.test.ts +58 -13
  12. package/src/components/gitops/detail-helpers.ts +37 -6
  13. package/src/components/gitops/index.ts +1 -0
  14. package/src/components/issues/IssuesView.tsx +16 -11
  15. package/src/components/issues/diagnostic.ts +2 -0
  16. package/src/components/issues/severity.ts +1 -0
  17. package/src/components/logs/LogCore.tsx +13 -11
  18. package/src/components/logs/LogsViewer.test.tsx +104 -59
  19. package/src/components/logs/LogsViewer.tsx +28 -8
  20. package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
  21. package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
  22. package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
  23. package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
  24. package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
  25. package/src/components/resources/ResourcesSidebar.tsx +62 -26
  26. package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
  27. package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
  28. package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
  29. package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
  30. package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
  31. package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
  32. package/src/components/resources/ResourcesView.tsx +342 -83
  33. package/src/components/resources/curated-column-ownership.test.ts +3 -3
  34. package/src/components/resources/generic-status.ts +15 -0
  35. package/src/components/resources/printer-columns.test.ts +36 -0
  36. package/src/components/resources/printer-columns.ts +12 -1
  37. package/src/components/resources/readiness-gates.test.ts +95 -0
  38. package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
  39. package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
  40. package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
  41. package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
  42. package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
  43. package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
  44. package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
  45. package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
  46. package/src/components/resources/renderers/PodRenderer.tsx +32 -9
  47. package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
  48. package/src/components/resources/renderers/index.ts +3 -0
  49. package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
  50. package/src/components/resources/resource-utils-crossplane.ts +44 -8
  51. package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
  52. package/src/components/resources/resource-utils-flagger.ts +76 -0
  53. package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
  54. package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
  55. package/src/components/resources/resource-utils-trivy.test.ts +87 -0
  56. package/src/components/resources/resource-utils-trivy.ts +122 -0
  57. package/src/components/resources/resource-utils.ts +80 -2
  58. package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
  59. package/src/components/shared/CreateResourceDialog.tsx +5 -0
  60. package/src/components/shared/DetailShell.phone.test.tsx +54 -0
  61. package/src/components/shared/DetailShell.tsx +21 -8
  62. package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
  63. package/src/components/shared/EditableYamlView.test.tsx +58 -0
  64. package/src/components/shared/EditableYamlView.tsx +18 -3
  65. package/src/components/shared/FilterRailPanel.test.tsx +39 -0
  66. package/src/components/shared/FilterRailPanel.tsx +62 -0
  67. package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
  68. package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
  69. package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
  70. package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
  71. package/src/components/shared/ManagedByChip.test.tsx +22 -0
  72. package/src/components/shared/ManagedByChip.tsx +158 -5
  73. package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
  74. package/src/components/shared/ResourceActionsBar.tsx +31 -9
  75. package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
  76. package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
  77. package/src/components/shared/SetImageDialog.tsx +3 -0
  78. package/src/components/timeline/TimelineList.tsx +5 -1
  79. package/src/components/topology/TopologyControls.tsx +1 -1
  80. package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
  81. package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
  82. package/src/components/ui/FetchResult.test.tsx +19 -2
  83. package/src/components/ui/FetchResult.tsx +13 -1
  84. package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
  85. package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
  86. package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
  87. package/src/components/ui/MultiSelectPicker.tsx +20 -2
  88. package/src/components/ui/PageHeader.tsx +7 -4
  89. package/src/components/ui/SummaryTile.tsx +13 -3
  90. package/src/components/ui/Toast.tsx +33 -26
  91. package/src/components/ui/Tooltip.touch.test.tsx +55 -0
  92. package/src/components/ui/Tooltip.tsx +11 -2
  93. package/src/components/ui/YamlReview.test.tsx +52 -0
  94. package/src/components/ui/YamlReview.tsx +7 -0
  95. package/src/components/ui/drawer-components.test.tsx +280 -0
  96. package/src/components/ui/drawer-components.tsx +302 -18
  97. package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
  98. package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
  99. package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
  100. package/src/components/workload/WorkloadView.tsx +105 -34
  101. package/src/hooks/index.ts +2 -0
  102. package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
  103. package/src/hooks/useKeepInViewport.test.ts +27 -0
  104. package/src/hooks/useKeepInViewport.ts +87 -0
  105. package/src/hooks/useMediaQuery.ts +21 -0
  106. package/src/hooks/usePhoneLayout.ts +12 -0
  107. package/src/theme/tailwind-theme.css +5 -0
  108. package/src/types/core.ts +119 -3
  109. package/src/types/gitops-flux-inventory.test.ts +24 -0
  110. package/src/types/gitops.ts +5 -5
  111. package/src/types/index.ts +3 -0
  112. package/src/types/read-coverage.ts +65 -0
  113. package/src/types/resource-coverage.ts +40 -0
  114. package/src/utils/api-resources.ts +1 -0
  115. package/src/utils/format.test.ts +7 -0
  116. package/src/utils/format.ts +6 -1
  117. package/src/utils/gitops-owner.test.ts +42 -1
  118. package/src/utils/gitops-owner.ts +158 -8
  119. package/src/utils/gitops-write-guard.test.ts +332 -0
  120. package/src/utils/gitops-write-guard.ts +175 -11
  121. package/src/utils/index.ts +3 -0
  122. package/src/utils/read-actions.test.ts +22 -0
  123. package/src/utils/read-actions.ts +90 -0
  124. package/src/utils/resource-coverage.test.ts +121 -0
  125. package/src/utils/resource-coverage.ts +117 -0
  126. package/src/utils/touch-intent.test.ts +39 -0
  127. package/src/utils/touch-intent.ts +35 -0
  128. package/vitest.config.ts +26 -0
@@ -1,3 +1,9 @@
1
+ import { getOpenTelemetryCollectorStatus, getOpenTelemetryInstrumentationStatus } from '../resources/resource-utils-opentelemetry'
2
+ import { OpenTelemetryCollectorRenderer, OpenTelemetryInstrumentationRenderer } from '../resources/renderers/OpenTelemetryRenderers'
3
+ import { FlaggerCanaryRenderer, FlaggerMetricTemplateRenderer } from '../resources/renderers/FlaggerRenderers'
4
+ import { getFlaggerCanaryStatus } from '../resources/resource-utils-flagger'
5
+ import { getGitOpsTool } from '../gitops/detail-helpers'
6
+ import { apiVersionToGroup } from '../../utils/navigation'
1
7
  import { hasReflectorDetails } from '../resources/renderers/ReflectorSection'
2
8
  import { clsx } from 'clsx'
3
9
  import { SEVERITY_BADGE, HEALTH_BADGE_COLORS } from '../../utils/badge-colors'
@@ -293,6 +299,7 @@ import {
293
299
  import type { ComposedRefStatus } from '../resources/renderers/CompositeRenderer'
294
300
  import {
295
301
  getCrossplaneStatus,
302
+ getCrossplanePauseState,
296
303
  getProviderStatus,
297
304
  getProviderConfigStatus,
298
305
  isManagedResource,
@@ -448,6 +455,8 @@ export interface RendererOverrides {
448
455
 
449
456
  // Known resource types with specific renderers (module-level to avoid re-allocation)
450
457
  const KNOWN_KINDS = new Set([
458
+ 'opentelemetrycollectors', 'instrumentations',
459
+ 'canaries', 'metrictemplates',
451
460
  'pods', 'deployments', 'statefulsets', 'daemonsets', 'replicasets',
452
461
  'services', 'endpointslices', 'ingresses', 'configmaps', 'secrets', 'jobs', 'cronjobs', 'cronworkflows',
453
462
  'jobsets',
@@ -552,6 +561,8 @@ interface ResourceRendererDispatchProps {
552
561
  resource: SelectedResource
553
562
  data: any
554
563
  relationships?: Relationships
564
+ /** See RelatedResourcesSection. */
565
+ trackedBy?: string
555
566
  certificateInfo?: SecretCertificateInfo
556
567
  hpaDiagnosis?: HPADiagnosis
557
568
  scalerDiagnostics?: ScalerDiagnosis[]
@@ -598,6 +609,7 @@ export function ResourceRendererDispatch({
598
609
  resource,
599
610
  data,
600
611
  relationships,
612
+ trackedBy,
601
613
  certificateInfo,
602
614
  hpaDiagnosis,
603
615
  scalerDiagnostics,
@@ -701,8 +713,13 @@ export function ResourceRendererDispatch({
701
713
  // foreign CRD render a BLANK drawer rather than the generic one.
702
714
  // CAPI's `machines` and `machinesets` need the same protection because another
703
715
  // API group can publish either plural without inheriting CAPI presentation.
716
+ const isFluxKind = ['gitrepositories', 'ocirepositories', 'helmrepositories', 'kustomizations', 'helmreleases', 'alerts'].includes(kind)
717
+ const isFluxResource = getGitOpsTool(kind, apiVersionToGroup(data?.apiVersion)) === 'flux'
704
718
  const isGroupGatedKind =
705
- kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
719
+ ['opentelemetrycollectors', 'instrumentations'].includes(kind) ||
720
+ ['canaries', 'metrictemplates'].includes(kind) ||
721
+ isFluxKind || kind === 'applications' || kind === 'services' || kind === 'ingresses' || kind === 'certificates' || kind === 'gateways'
722
+ || kind === 'clusters' || kind === 'backups' || kind === 'scheduledbackups' || kind === 'poolers'
706
723
  || kind === 'objectstores' || kind === 'databases' || kind === 'publications'
707
724
  || kind === 'subscriptions' || kind === 'imagecatalogs' || kind === 'clusterimagecatalogs' || kind === 'databaseroles' || kind === 'jobsets'
708
725
  || kind === 'policies' || kind === 'rollouts' || kind === 'experiments'
@@ -710,7 +727,15 @@ export function ResourceRendererDispatch({
710
727
  || kind === 'machines' || kind === 'machinesets' || kind === 'workloads' || kind === 'localqueues' || kind === 'clusterqueues'
711
728
  const isCNPGApiVersion = isApiGroup(data?.apiVersion, CNPG_GROUP)
712
729
  const groupGatedMatched =
713
- (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
730
+ (['opentelemetrycollectors', 'instrumentations'].includes(kind) && isApiGroup(data?.apiVersion, 'opentelemetry.io')) ||
731
+ (['canaries', 'metrictemplates'].includes(kind) && isApiGroup(data?.apiVersion, 'flagger.app')) ||
732
+ (isFluxKind && isFluxResource)
733
+ || (kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io'))
734
+ || (kind === 'services' && (data?.apiVersion === 'v1' || isApiGroup(data?.apiVersion, 'serving.knative.dev')))
735
+ || (kind === 'ingresses' && ['networking.k8s.io', 'extensions', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
736
+ || (kind === 'certificates' && ['cert-manager.io', 'networking.internal.knative.dev'].some(group => isApiGroup(data?.apiVersion, group)))
737
+ || (kind === 'gateways' && ['gateway.networking.k8s.io', 'networking.istio.io'].some(group => isApiGroup(data?.apiVersion, group)))
738
+ || (kind === 'clusters' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'cluster.x-k8s.io')))
714
739
  || (kind === 'backups' && (isCNPGApiVersion || isApiGroup(data?.apiVersion, 'velero.io')))
715
740
  || ((kind === 'scheduledbackups' || kind === 'poolers') && isCNPGApiVersion)
716
741
  || (kind === 'objectstores' && isApiGroup(data?.apiVersion, 'barmancloud.cnpg.io'))
@@ -799,7 +824,7 @@ export function ResourceRendererDispatch({
799
824
 
800
825
  const sidebarContent = showCommonSections && (
801
826
  <>
802
- <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} />
827
+ <RelatedResourcesSection relationships={sidebarRelationships} onNavigate={onNavigate} kind={kind} trackedBy={trackedBy} />
803
828
  {kind !== 'events' && <EventsSection fullscreen={!!renderSidebar} events={events || []} updates={updates || []} isLoading={eventsLoading ?? false} eventsError={eventsError ?? null} updatesError={updatesError ?? null} hint={eventsHint} />}
804
829
  <LabelsSection data={data} />
805
830
  <AnnotationsSection data={data} />
@@ -824,9 +849,9 @@ export function ResourceRendererDispatch({
824
849
  />
825
850
  )}
826
851
  {kind === 'replicasets' && <ReplicaSetRenderer data={data} />}
827
- {kind === 'services' && !data?.apiVersion?.includes('serving.knative.dev') && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
852
+ {kind === 'services' && data?.apiVersion === 'v1' && <ServiceComp data={data} onCopy={onCopy} copied={copied} onNavigate={onNavigate} />}
828
853
  {kind === 'endpointslices' && <EndpointSliceRenderer data={data} onNavigate={onNavigate} />}
829
- {kind === 'ingresses' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <IngressRenderer data={data} onNavigate={onNavigate} />}
854
+ {kind === 'ingresses' && (isApiGroup(data?.apiVersion, 'networking.k8s.io') || isApiGroup(data?.apiVersion, 'extensions')) && <IngressRenderer data={data} onNavigate={onNavigate} />}
830
855
  {kind === 'configmaps' && <ConfigMapRenderer data={data} relationships={relationships} onNavigate={onNavigate} />}
831
856
  {kind === 'secrets' && <SecretRenderer data={data} relationships={relationships} onNavigate={onNavigate} certificateInfo={certificateInfo} resourceData={data} onSaveSecretValue={onSaveSecretValue} isSaving={isSavingSecret} />}
832
857
  {kind === 'jobs' && !nonCoreJobFallthrough && <JobComp data={data} onNavigate={onNavigate} />}
@@ -848,7 +873,7 @@ export function ResourceRendererDispatch({
848
873
  {kind === 'analysisruns' && <AnalysisRunRenderer data={data} onNavigate={onNavigate} />}
849
874
  {(kind === 'analysistemplates' || kind === 'clusteranalysistemplates') && <AnalysisTemplateRenderer data={data} />}
850
875
  {kind === 'experiments' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ExperimentRenderer data={data} onNavigate={onNavigate} />}
851
- {kind === 'certificates' && !data?.apiVersion?.includes('networking.internal.knative.dev') && <CertificateRenderer data={data} />}
876
+ {kind === 'certificates' && isApiGroup(data?.apiVersion, 'cert-manager.io') && <CertificateRenderer data={data} />}
852
877
  {kind === 'workflows' && <WorkflowRenderer data={data} onNavigate={onNavigate} />}
853
878
  {kind === 'persistentvolumes' && <PersistentVolumeRenderer data={data} onNavigate={onNavigate} />}
854
879
  {kind === 'storageclasses' && <StorageClassRenderer data={data} />}
@@ -857,7 +882,7 @@ export function ResourceRendererDispatch({
857
882
  {kind === 'issuers' && <IssuerRenderer data={data} />}
858
883
  {kind === 'orders' && <OrderRenderer data={data} />}
859
884
  {kind === 'challenges' && <ChallengeRenderer data={data} />}
860
- {kind === 'gateways' && (data.apiVersion?.includes('networking.istio.io') ? <IstioGatewayRenderer data={data} /> : <GatewayRenderer data={data} onNavigate={onNavigate} />)}
885
+ {kind === 'gateways' && groupGatedMatched && (isApiGroup(data.apiVersion, 'networking.istio.io') ? <IstioGatewayRenderer data={data} /> : <GatewayRenderer data={data} onNavigate={onNavigate} />)}
861
886
  {kind === 'gatewayclasses' && <GatewayClassRenderer data={data} />}
862
887
  {kind === 'httproutes' && <HTTPRouteRenderer data={data} onNavigate={onNavigate} />}
863
888
  {kind === 'grpcroutes' && <GRPCRouteRenderer data={data} onNavigate={onNavigate} />}
@@ -877,13 +902,13 @@ export function ResourceRendererDispatch({
877
902
  {(kind === 'roles' || kind === 'clusterroles') && <RoleComp data={data} onNavigate={onNavigate} />}
878
903
  {(kind === 'rolebindings' || kind === 'clusterrolebindings') && <RoleBindingComp data={data} onNavigate={onNavigate} />}
879
904
  {kind === 'events' && <EventRenderer data={data} onNavigate={onNavigate} />}
880
- {kind === 'gitrepositories' && <GitRepositoryRenderer data={data} />}
881
- {kind === 'ocirepositories' && <OCIRepositoryRenderer data={data} />}
882
- {kind === 'helmrepositories' && <HelmRepositoryRenderer data={data} />}
883
- {kind === 'kustomizations' && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
884
- {kind === 'helmreleases' && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
885
- {kind === 'alerts' && <AlertRenderer data={data} />}
886
- {kind === 'applications' && <ArgoApplicationRenderer data={data} />}
905
+ {kind === 'gitrepositories' && isFluxResource && <GitRepositoryRenderer data={data} />}
906
+ {kind === 'ocirepositories' && isFluxResource && <OCIRepositoryRenderer data={data} />}
907
+ {kind === 'helmrepositories' && isFluxResource && <HelmRepositoryRenderer data={data} />}
908
+ {kind === 'kustomizations' && isFluxResource && <KustomizationRenderer data={data} onNavigate={onNavigate} />}
909
+ {kind === 'helmreleases' && isFluxResource && <FluxHelmReleaseComp data={data} onNavigate={onNavigate} />}
910
+ {kind === 'alerts' && isFluxResource && <AlertRenderer data={data} />}
911
+ {kind === 'applications' && isApiGroup(data?.apiVersion, 'argoproj.io') && <ArgoApplicationRenderer data={data} />}
887
912
  {kind === 'nodepools' && <KarpenterNodePoolComp data={data} onNavigate={onNavigate} />}
888
913
  {kind === 'nodeclaims' && <KarpenterNodeClaimRenderer data={data} onNavigate={onNavigate} />}
889
914
  {kind === 'ec2nodeclasses' && <KarpenterEC2NodeClassRenderer data={data} />}
@@ -989,14 +1014,14 @@ export function ResourceRendererDispatch({
989
1014
  {kind === 'leases' && <LeaseRenderer data={data} />}
990
1015
  {kind === 'limitranges' && <LimitRangeRenderer data={data} />}
991
1016
  {/* Knative Serving */}
992
- {(kind === 'services' && data?.apiVersion?.includes('serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
1017
+ {(kind === 'services' && isApiGroup(data?.apiVersion, 'serving.knative.dev')) && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
993
1018
  {kind === 'knativeservices' && <KnativeServiceRenderer data={data} onNavigate={onNavigate} />}
994
1019
  {kind === 'knativeconfigurations' && <KnativeConfigurationRenderer data={data} onNavigate={onNavigate} />}
995
1020
  {kind === 'knativerevisions' && <KnativeRevisionRenderer data={data} onNavigate={onNavigate} />}
996
1021
  {kind === 'knativeroutes' && <KnativeRouteRenderer data={data} onNavigate={onNavigate} />}
997
- {(kind === 'ingresses' && data?.apiVersion?.includes('networking.internal.knative.dev')) && <KnativeIngressRenderer data={data} />}
1022
+ {(kind === 'ingresses' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeIngressRenderer data={data} />}
998
1023
  {kind === 'knativeingresses' && <KnativeIngressRenderer data={data} />}
999
- {(kind === 'certificates' && data?.apiVersion?.includes('networking.internal.knative.dev')) && <KnativeCertificateRenderer data={data} />}
1024
+ {(kind === 'certificates' && isApiGroup(data?.apiVersion, 'networking.internal.knative.dev')) && <KnativeCertificateRenderer data={data} />}
1000
1025
  {kind === 'knativecertificates' && <KnativeCertificateRenderer data={data} />}
1001
1026
  {kind === 'serverlessservices' && <ServerlessServiceRenderer data={data} onNavigate={onNavigate} />}
1002
1027
  {kind === 'domainmappings' && <DomainMappingRenderer data={data} onNavigate={onNavigate} />}
@@ -1038,6 +1063,11 @@ export function ResourceRendererDispatch({
1038
1063
  {isCrossplaneMR && <ManagedResourceRenderer data={data} onNavigate={onNavigate} />}
1039
1064
  {(isCrossplaneXR || isCrossplaneClaim) && <CompositeComp data={data} onNavigate={onNavigate} />}
1040
1065
 
1066
+ {kind === 'opentelemetrycollectors' && isApiGroup(data?.apiVersion, 'opentelemetry.io') && <OpenTelemetryCollectorRenderer data={data} />}
1067
+ {kind === 'instrumentations' && isApiGroup(data?.apiVersion, 'opentelemetry.io') && <OpenTelemetryInstrumentationRenderer data={data} />}
1068
+ {kind === 'canaries' && isApiGroup(data?.apiVersion, 'flagger.app') && <FlaggerCanaryRenderer data={data} onNavigate={onNavigate} />}
1069
+ {kind === 'metrictemplates' && isApiGroup(data?.apiVersion, 'flagger.app') && <FlaggerMetricTemplateRenderer data={data} onNavigate={onNavigate} />}
1070
+
1041
1071
  {/* Generic renderer for CRDs and unknown resource types — also fires
1042
1072
  for known-plural collisions where no apiVersion-gated renderer
1043
1073
  matched (e.g. a Knative Configuration sharing the `configurations`
@@ -1095,17 +1125,22 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1095
1125
  if (!data) return null
1096
1126
  const k = kind.toLowerCase()
1097
1127
 
1128
+ if (isApiGroup(data.apiVersion, 'opentelemetry.io')) {
1129
+ if (k === 'opentelemetrycollectors') return getOpenTelemetryCollectorStatus(data)
1130
+ if (k === 'instrumentations') return getOpenTelemetryInstrumentationStatus(data)
1131
+ }
1132
+ if (k === 'canaries' && isApiGroup(data.apiVersion, 'flagger.app')) return getFlaggerCanaryStatus(data)
1098
1133
  if (k === 'pods') return getPodStatus(data)
1099
1134
  if (['deployments', 'statefulsets', 'daemonsets', 'rollouts'].includes(k)) {
1100
1135
  return getWorkloadDisplayStatus(data, k).status
1101
1136
  }
1102
1137
  if (k === 'replicasets') return getWorkloadStatus(data, k)
1103
1138
  if (k === 'services') {
1104
- if (data.apiVersion?.includes('serving.knative.dev')) {
1139
+ if (isApiGroup(data.apiVersion, 'serving.knative.dev')) {
1105
1140
  const status = getKnativeConditionStatus(data)
1106
1141
  return { text: status.text, color: status.color }
1107
1142
  }
1108
- return getServiceStatus(data)
1143
+ if (data.apiVersion === 'v1') return getServiceStatus(data)
1109
1144
  }
1110
1145
  if (k === 'endpointslices') {
1111
1146
  const endpoints = data.endpoints || []
@@ -1178,11 +1213,11 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1178
1213
  if (k === 'workflows') return getWorkflowStatus(data)
1179
1214
  if (k === 'cronworkflows') return getCronWorkflowStatus(data)
1180
1215
  if (k === 'certificates') {
1181
- if (data.apiVersion?.includes('networking.internal.knative.dev')) {
1216
+ if (isApiGroup(data.apiVersion, 'networking.internal.knative.dev')) {
1182
1217
  const status = getKnativeConditionStatus(data)
1183
1218
  return { text: status.text, color: status.color }
1184
1219
  }
1185
- return getCertificateStatus(data)
1220
+ if (isApiGroup(data.apiVersion, 'cert-manager.io')) return getCertificateStatus(data)
1186
1221
  }
1187
1222
  if (k === 'persistentvolumes') return getPVStatus(data)
1188
1223
  if (k === 'certificaterequests') return getCertificateRequestStatus(data)
@@ -1191,20 +1226,21 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1191
1226
  if (k === 'orders') return getOrderState(data)
1192
1227
  if (k === 'challenges') return getChallengeState(data)
1193
1228
  if (k === 'gateways') {
1194
- if (data.apiVersion?.includes('networking.istio.io')) return getIstioGatewayStatus(data)
1195
- return getGatewayStatus(data)
1229
+ if (isApiGroup(data.apiVersion, 'networking.istio.io')) return getIstioGatewayStatus(data)
1230
+ if (isApiGroup(data.apiVersion, 'gateway.networking.k8s.io')) return getGatewayStatus(data)
1196
1231
  }
1197
1232
  if (k === 'gatewayclasses') return getGatewayClassStatus(data)
1198
1233
  if (k === 'httproutes' || k === 'grpcroutes' || k === 'tcproutes' || k === 'tlsroutes' || k === 'udproutes') return getRouteStatus(data)
1199
1234
  if (k === 'sealedsecrets') return getSealedSecretStatus(data)
1200
1235
  if (k === 'poddisruptionbudgets') return getPDBStatus(data)
1201
- if (k === 'gitrepositories') return getGitRepositoryStatus(data)
1202
- if (k === 'ocirepositories') return getOCIRepositoryStatus(data)
1203
- if (k === 'helmrepositories') return getHelmRepositoryStatus(data)
1204
- if (k === 'kustomizations') return getKustomizationStatus(data)
1205
- if (k === 'helmreleases') return getFluxHelmReleaseStatus(data)
1206
- if (k === 'alerts') return getFluxAlertStatus(data)
1207
- if (k === 'applications') return getArgoApplicationStatus(data)
1236
+ const isFluxResource = getGitOpsTool(k, apiVersionToGroup(data.apiVersion)) === 'flux'
1237
+ if (k === 'gitrepositories' && isFluxResource) return getGitRepositoryStatus(data)
1238
+ if (k === 'ocirepositories' && isFluxResource) return getOCIRepositoryStatus(data)
1239
+ if (k === 'helmrepositories' && isFluxResource) return getHelmRepositoryStatus(data)
1240
+ if (k === 'kustomizations' && isFluxResource) return getKustomizationStatus(data)
1241
+ if (k === 'helmreleases' && isFluxResource) return getFluxHelmReleaseStatus(data)
1242
+ if (k === 'alerts' && isFluxResource) return getFluxAlertStatus(data)
1243
+ if (k === 'applications' && isApiGroup(data.apiVersion, 'argoproj.io')) return getArgoApplicationStatus(data)
1208
1244
  if (k === 'nodepools') return getNodePoolStatus(data)
1209
1245
  if (k === 'nodeclaims') return getNodeClaimStatus(data)
1210
1246
  if (k === 'ec2nodeclasses') return getEC2NodeClassStatus(data)
@@ -1349,12 +1385,14 @@ export function getResourceStatus(kind: string, data: any): { text: string; colo
1349
1385
  if (
1350
1386
  knativeConditionKinds.includes(k)
1351
1387
  || isKnativeSubscription
1352
- || (k === 'ingresses' && data.apiVersion?.includes('networking.internal.knative.dev'))
1388
+ || (k === 'ingresses' && isApiGroup(data.apiVersion, 'networking.internal.knative.dev'))
1353
1389
  ) {
1354
1390
  const status = getKnativeConditionStatus(data)
1355
1391
  return { text: status.text, color: status.color }
1356
1392
  }
1357
1393
 
1394
+ if (getCrossplanePauseState(data) === 'acknowledged') return getCrossplaneStatus(data)
1395
+
1358
1396
  const derived = getGenericResourceStatus(data)
1359
1397
  if (derived) return { text: derived.text, color: HEALTH_BADGE_COLORS[derived.tone] }
1360
1398
 
@@ -17,6 +17,7 @@ import {
17
17
  type GitOpsWriteGuard,
18
18
  } from '../../utils/gitops-write-guard'
19
19
  import { GitOpsWriteWarning } from './GitOpsWriteWarning'
20
+ import type { GitOpsOwnerRef } from '../../utils/gitops-owner'
20
21
 
21
22
  /** The GitOps write guard for the object whose Pod template the dialog
22
23
  * writes, evaluated by the host: for {@link SET_IMAGE_WRITES} until the
@@ -25,6 +26,7 @@ import { GitOpsWriteWarning } from './GitOpsWriteWarning'
25
26
  export interface SetImageOwnership {
26
27
  guard: GitOpsWriteGuard
27
28
  onOpenOwner?: () => void
29
+ onOpenManager?: (owner: GitOpsOwnerRef) => void
28
30
  /** The containers the dialog lists and the ones being changed, so the
29
31
  * guard can judge exactly those (null until the containers load). */
30
32
  onSelectionChange?: (selection: SetImageSelection | null) => void
@@ -435,6 +437,7 @@ export function SetImageDialog({
435
437
  acknowledged={acknowledged}
436
438
  onAcknowledgedChange={(value) => setAckedKey(value ? guardKey : '')}
437
439
  onOpenOwner={ownership?.onOpenOwner}
440
+ onOpenManager={ownership?.onOpenManager}
438
441
  disabled={busy}
439
442
  />
440
443
  )}
@@ -216,6 +216,10 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
216
216
  const singleEvents: TimelineEvent[] = []
217
217
 
218
218
  for (const item of items) {
219
+ if (item.id === selectedEventId) {
220
+ singleEvents.push(item)
221
+ continue
222
+ }
219
223
  if (foldPerResource?.(item)) {
220
224
  const key = `fold:${item.kind}:${item.namespace}:${item.name}`
221
225
  const existing = groups.get(key) || []
@@ -344,7 +348,7 @@ export function TimelineList({ events, isLoading, onRefresh, onQueryChange, hasL
344
348
  if (older.length > 0) groups.push({ label: 'Older', items: aggregateEvents(older) })
345
349
 
346
350
  return groups
347
- }, [filteredActivity, compact, foldPerResource])
351
+ }, [filteredActivity, compact, foldPerResource, selectedEventId])
348
352
 
349
353
  // Visible-window reporting: rows carry their time span in data attributes;
350
354
  // on scroll (rAF-throttled) and whenever the row set changes, the span of
@@ -90,7 +90,7 @@ export function TopologyControls({
90
90
  return (
91
91
  // Layout-neutral (positioned by the host's overlay bar): `relative` anchors
92
92
  // its own dropdowns; `pointer-events-auto` inside the pointer-events-none bar.
93
- <div className="relative flex items-center gap-2 pointer-events-auto">
93
+ <div className="relative flex items-center gap-2 pointer-events-auto phone:flex-wrap phone:gap-1.5">
94
94
  {/* Freshness/liveness status — backed for legibility over the canvas but
95
95
  borderless + divided off, so it reads as a status, not another control. */}
96
96
  {leadingSlot && (
@@ -0,0 +1,153 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { renderToStaticMarkup } from 'react-dom/server'
3
+ import { stringify } from 'yaml'
4
+ import { CrossplaneApplyNotice, CrossplaneDeleteNotice, CrossplaneYamlApplyNotice } from './CrossplaneOperationNotice'
5
+
6
+ const mr = {
7
+ apiVersion: 's3.example.test/v1', kind: 'Bucket',
8
+ metadata: { name: 'archive', annotations: { 'crossplane.io/paused': 'true' }, finalizers: ['finalizer.managedresource.crossplane.io'] },
9
+ spec: { providerConfigRef: { name: 'default' } },
10
+ }
11
+ const xr = { ...mr, kind: 'Composite', spec: { resourceRefs: [] }, metadata: { ...mr.metadata, finalizers: ['composite.apiextensions.crossplane.io'] } }
12
+ const claim = { ...mr, kind: 'Claim', spec: { resourceRef: { name: 'xr' }, compositionRef: { name: 'composition' } }, metadata: { ...mr.metadata, finalizers: ['finalizer.apiextensions.crossplane.io'] } }
13
+ const html = (element: React.ReactNode) => renderToStaticMarkup(element)
14
+
15
+ describe('Crossplane operation context', () => {
16
+ it.each([mr, xr, claim])('identifies its own paused deletion cleanup for $kind', data => {
17
+ const result = html(<CrossplaneDeleteNotice resources={[data]} />)
18
+ expect(result).toContain('This resource holds a Crossplane finalizer')
19
+ expect(result).toContain('Kubernetes can accept the deletion request')
20
+ expect(result).toContain('defer finalizer cleanup')
21
+ expect(result).not.toContain('stuck in Terminating')
22
+ expect(result).toContain('requests resumption')
23
+ expect(result).not.toContain('will delete external')
24
+ })
25
+ it('distinguishes an already requested deletion', () => {
26
+ const result = html(<CrossplaneDeleteNotice resources={[{ ...mr, metadata: { ...mr.metadata, deletionTimestamp: '2026-10-26T00:00:00Z' } }]} />)
27
+ expect(result).toContain('Deletion requested while reconciliation is paused')
28
+ expect(result).toContain('Deletion has already been requested')
29
+ expect(result).not.toContain('Kubernetes can accept')
30
+ })
31
+ it('avoids claiming pause alone blocks deletion without a known finalizer', () => {
32
+ const result = html(<CrossplaneDeleteNotice resources={[{ ...mr, metadata: { ...mr.metadata, finalizers: ['other.example.test/cleanup'] } }]} />)
33
+ expect(result).toContain('pause annotation alone does not block Kubernetes deletion')
34
+ expect(result).not.toContain('cannot complete its finalizer cleanup')
35
+ })
36
+ it('explains the existing explicit force choice without promising a cascade', () => {
37
+ const result = html(<CrossplaneDeleteNotice resources={[mr]} force />)
38
+ expect(result).toContain('Force delete strips finalizers')
39
+ expect(result).toContain('can leave external or related resources behind')
40
+ expect(result).toContain('does not resume reconciliation')
41
+ expect(result).toContain('This resource has finalizers')
42
+ expect(result).not.toContain('These resources have finalizers')
43
+ })
44
+ it('names only the paused finalizer holders in a mixed blocker warning', () => {
45
+ const bare = { ...mr, metadata: { ...mr.metadata, name: 'bare', finalizers: [] } }
46
+ const active = { ...mr, metadata: { ...mr.metadata, name: 'active', annotations: {} } }
47
+ const result = html(<CrossplaneDeleteNotice resources={[mr, bare, active]} />)
48
+ expect(result).toContain('Of 3 selected resources, 1 has pause requested and holds')
49
+ expect(result).toContain('Bucket archive')
50
+ expect(result).not.toContain('Bucket bare')
51
+ expect(result).not.toContain('Bucket active')
52
+ const mixedForce = html(<CrossplaneDeleteNotice resources={[mr, bare, active]} force />)
53
+ expect(mixedForce).toContain('Of 3 selected resources, 1 has pause requested and finalizers')
54
+ const forced = html(<CrossplaneDeleteNotice resources={[bare]} force />)
55
+ expect(forced).toContain('Bucket bare')
56
+ expect(forced).not.toContain('0 of 1')
57
+ expect(forced).not.toContain('Force delete bypasses')
58
+ expect(forced).toContain('pause annotation alone does not block')
59
+ })
60
+ it('counts a mixed selection and caps names', () => {
61
+ const resources = Array.from({ length: 7 }, (_, i) => ({ ...mr, metadata: { ...mr.metadata, name: `paused-${i}` } }))
62
+ const active = { ...mr, metadata: { ...mr.metadata, name: 'active', annotations: {} } }
63
+ const result = html(<CrossplaneDeleteNotice resources={[...resources, active]} />)
64
+ expect(result).toContain('Of 8 selected resources, 7 have pause requested and hold')
65
+ expect(result).toContain('and 2 more')
66
+ expect(result).not.toContain('paused-6')
67
+ expect(result).not.toContain('Bucket active')
68
+ })
69
+ it.each([undefined, { metadata: mr.metadata }, { ...mr, metadata: { annotations: { 'crossplane.io/paused': 'false' } } }, { ...mr, metadata: { annotations: { 'crossplane.io/paused': true } } }])('omits context for unrelated or unannotated input %#', data => {
70
+ expect(html(<CrossplaneDeleteNotice resources={[data]} />)).toBe('')
71
+ expect(html(<CrossplaneApplyNotice resources={[data]} />)).toBe('')
72
+ })
73
+ it('retains own-controller context while independent failures exist', () => {
74
+ const data = { ...mr, status: { conditions: [{ type: 'Ready', status: 'False', reason: 'Unavailable' }] } }
75
+ expect(html(<CrossplaneApplyNotice resources={[data]} />)).toContain('Other resources&#x27; controllers may continue independently')
76
+ expect(data.status.conditions[0].reason).toBe('Unavailable')
77
+ })
78
+ it('uses conditional wording when an apply draft omits a pause owned by another manager', () => {
79
+ const after = { ...mr, metadata: { ...mr.metadata, annotations: {} } }
80
+ const result = html(<CrossplaneYamlApplyNotice yaml={stringify(after)} baselineYaml={stringify(mr)} />)
81
+ expect(result).toContain('Kubernetes can accept edits')
82
+ expect(result).toContain('requests resumption')
83
+ expect(result).toContain('Omitting the annotation from server-side apply YAML does not guarantee its removal')
84
+ expect(result).not.toContain('will resume')
85
+ })
86
+ it('detects pause added by the submitted or predicted manifest', () => {
87
+ expect(html(<CrossplaneYamlApplyNotice yaml={stringify(mr)} />)).toContain('Pause affects reconciliation')
88
+ })
89
+ it('names unknown live pause state only for apply without a baseline', () => {
90
+ const unpausedDraft = { ...mr, metadata: { ...mr.metadata, annotations: {} } }
91
+ const yaml = stringify(unpausedDraft)
92
+ const result = html(<CrossplaneYamlApplyNotice yaml={yaml} baselineUnavailable />)
93
+ expect(result).toContain('Existing pause state is unknown')
94
+ expect(result).toContain('Omitting the annotation from server-side apply YAML does not guarantee its removal')
95
+ expect(result).not.toContain('Pause affects reconciliation')
96
+ expect(html(<CrossplaneYamlApplyNotice yaml={yaml} />)).toBe('')
97
+ expect(html(<CrossplaneYamlApplyNotice yaml="kind: ConfigMap" baselineUnavailable />)).toBe('')
98
+ })
99
+ it('retains explicit submitted pause context alongside unknown live state', () => {
100
+ const result = html(<CrossplaneYamlApplyNotice yaml={stringify(mr)} baselineUnavailable />)
101
+ expect(result).toContain('Pause affects reconciliation')
102
+ expect(result).toContain('Live pause state is unverified')
103
+ expect(result).toContain('requests resumption')
104
+ })
105
+ it('recognizes acknowledged ref-less objects without broadening annotation-only detection', () => {
106
+ const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
107
+ expect(html(<CrossplaneDeleteNotice resources={[raw]} />)).toContain('This resource holds a Crossplane finalizer')
108
+ const { status: _status, ...clean } = raw
109
+ expect(html(<CrossplaneYamlApplyNotice yaml={stringify(clean)} baselineUnavailable />)).toBe('')
110
+ expect(html(<CrossplaneApplyNotice resources={[{ ...raw, metadata: { ...raw.metadata, annotations: {} } }]} />)).toBe('')
111
+ })
112
+ it('binds raw acknowledgement to identity across versions without borrowing it for another API group', () => {
113
+ const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
114
+ const check = (apiVersion: string | number) => {
115
+ const yaml = stringify({ ...raw, apiVersion, status: undefined })
116
+ return html(<CrossplaneYamlApplyNotice yaml={yaml} baselineYaml={yaml} resourceData={raw} previewDocuments={[{ index: 0, status: 'accepted', baselineYaml: yaml, predictedYaml: yaml }]} />)
117
+ }
118
+ expect(check('another.example.test/v1')).toBe('')
119
+ expect(check('s3.example.test/v2')).toContain('Server preview keeps the pause request')
120
+ expect(() => check(42)).not.toThrow()
121
+ })
122
+ it('does not borrow old raw acknowledgement through an old baseline when the effective target changes group', () => {
123
+ const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
124
+ const baselineYaml = stringify({ ...raw, status: undefined })
125
+ const predictedYaml = stringify({ ...raw, apiVersion: 'another.example.test/v1', status: undefined })
126
+ const result = html(<CrossplaneYamlApplyNotice yaml={predictedYaml} baselineYaml={baselineYaml} resourceData={raw} previewDocuments={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml }]} />)
127
+ expect(result).toBe('')
128
+ })
129
+ it.each(['direct', 'unavailable'] as const)('does not borrow a paused shape from another group through the original baseline in %s review', mode => {
130
+ const baselineYaml = stringify(mr)
131
+ const submittedYaml = stringify({ apiVersion: 'another.example.test/v1', kind: mr.kind, metadata: { name: mr.metadata.name }, spec: {} })
132
+ const result = html(<CrossplaneYamlApplyNotice yaml={submittedYaml} baselineYaml={baselineYaml} resourceData={mr} previewDocuments={mode === 'unavailable' ? [{ index: 0, status: 'unavailable', action: 'unknown', submittedYaml }] : undefined} />)
133
+ expect(result).toBe('')
134
+ })
135
+ it('retains same-target pause clearing across served versions when the submitted shape changes', () => {
136
+ const baselineYaml = stringify(mr)
137
+ const predictedYaml = stringify({ ...mr, apiVersion: 's3.example.test/v2', metadata: { ...mr.metadata, annotations: { 'crossplane.io/paused': 'false' } }, spec: {} })
138
+ const result = html(<CrossplaneYamlApplyNotice yaml={predictedYaml} baselineYaml={baselineYaml} previewDocuments={[{ index: 0, status: 'accepted', action: 'update', baselineYaml, predictedYaml }]} />)
139
+ expect(result).toContain('Server preview clears the pause request on Bucket archive')
140
+ expect(result).toContain('requests resumption')
141
+ })
142
+ it('does not use one raw target to classify unrelated documents in a batch', () => {
143
+ const raw = { ...mr, kind: 'Claim', spec: {}, status: { conditions: [{ type: 'Synced', status: 'False', reason: 'ReconcilePaused' }] } }
144
+ const clean = { ...raw, status: undefined }
145
+ const yaml = stringify(clean)
146
+ const result = html(<CrossplaneYamlApplyNotice yaml={yaml + '\n---\n' + yaml} resourceData={raw} previewDocuments={[{ index: 0, status: 'accepted', baselineYaml: yaml, predictedYaml: yaml }, { index: 1, status: 'accepted', baselineYaml: yaml, predictedYaml: yaml }]} />)
147
+ expect(result).toBe('')
148
+ })
149
+ it('handles invalid or empty YAML without disturbing the editor validation', () => {
150
+ expect(html(<CrossplaneYamlApplyNotice yaml={'spec: [\n'} />)).toBe('')
151
+ expect(html(<CrossplaneYamlApplyNotice yaml="" />)).toBe('')
152
+ })
153
+ })
@@ -0,0 +1,139 @@
1
+ import { useMemo, type ReactNode } from 'react'
2
+ import type { YamlPreviewResult } from './YamlReview'
3
+ import { parseAllDocuments } from 'yaml'
4
+ import { splitYamlDocuments } from '../../utils/yaml'
5
+ import { apiVersionToGroup } from '../../utils/navigation'
6
+ import { resourceKey } from '../../utils/resource-refs'
7
+ import { getCrossplanePauseState, isComposite, isCrossplanePaused, isManagedResource } from '../resources/resource-utils-crossplane'
8
+ import { AlertBanner } from './drawer-components'
9
+
10
+ const crossplaneFinalizers = new Set([
11
+ 'finalizer.managedresource.crossplane.io',
12
+ 'composite.apiextensions.crossplane.io',
13
+ 'finalizer.apiextensions.crossplane.io',
14
+ ])
15
+
16
+ function isCrossplaneOperationResource(data: any): boolean {
17
+ return isManagedResource(data) || isComposite(data) || getCrossplanePauseState(data) === 'acknowledged'
18
+ }
19
+
20
+ function isAnnotatedCrossplanePause(data: any): boolean {
21
+ return isCrossplanePaused(data) && isCrossplaneOperationResource(data)
22
+ }
23
+
24
+ function resourceNames(resources: any[]): string {
25
+ const names = resources.slice(0, 5).map(data =>
26
+ `${data.kind ?? 'Resource'} ${data.metadata?.namespace ? `${data.metadata.namespace}/` : ''}${data.metadata?.name ?? 'Unnamed'}`,
27
+ )
28
+ if (resources.length > 5) names.push(`and ${resources.length - 5} more`)
29
+ return names.join(', ')
30
+ }
31
+
32
+ export function CrossplaneDeleteNotice({ resources, force = false }: { resources: any[]; force?: boolean }) {
33
+ const paused = resources.filter(isAnnotatedCrossplanePause)
34
+ if (paused.length === 0) return null
35
+ const blocked = paused.filter(data =>
36
+ data.metadata?.finalizers?.some((finalizer: string) => crossplaneFinalizers.has(finalizer)),
37
+ )
38
+ const forceTargets = paused.filter(data => data.metadata?.finalizers?.length > 0)
39
+ const alreadyDeleting = blocked.some(data => data.metadata?.deletionTimestamp)
40
+ if (force ? forceTargets.length === 0 : blocked.length === 0) {
41
+ return <p className="text-xs text-theme-text-secondary">
42
+ Reconciliation pause is requested for {resourceNames(paused)}. The pause annotation alone does not block Kubernetes deletion; remaining finalizers determine whether cleanup must finish first.
43
+ </p>
44
+ }
45
+ return <AlertBanner
46
+ variant="warning"
47
+ className="mb-2"
48
+ title={force ? 'Force delete bypasses controller cleanup' : alreadyDeleting ? 'Deletion requested while reconciliation is paused' : 'Pause can hold deletion in Terminating'}
49
+ message={<>
50
+ {force
51
+ ? resources.length === 1
52
+ ? <p>Reconciliation pause is requested for {resourceNames(forceTargets)}. This resource has finalizers.</p>
53
+ : <p>Of {resources.length} selected resources, {forceTargets.length} {forceTargets.length === 1 ? 'has' : 'have'} pause requested and finalizers: {resourceNames(forceTargets)}.</p>
54
+ : resources.length === 1
55
+ ? <p>Pause requested on {resourceNames(blocked)}. This resource holds a Crossplane finalizer.</p>
56
+ : <p>Of {resources.length} selected resources, {blocked.length} {blocked.length === 1 ? 'has' : 'have'} pause requested and {blocked.length === 1 ? 'holds' : 'hold'} a Crossplane finalizer: {resourceNames(blocked)}.</p>}
57
+ {force ? <p className="mt-1">Force delete strips finalizers and can leave external or related resources behind. It does not resume reconciliation or guarantee what other controllers will delete.</p> : <p className="mt-1">{alreadyDeleting ? resources.length === 1 ? 'Deletion has already been requested.' : 'Some deletions have already been requested.' : 'Kubernetes can accept the deletion request.'} The pause annotation tells {blocked.length === 1 ? 'this resource’s controller' : 'these resources’ controllers'} to defer finalizer cleanup. Removing the pause requests resumption and may allow pending work, including deletion under {blocked.length === 1 ? 'its policies' : 'their policies'}.</p>}
58
+ </>}
59
+ />
60
+ }
61
+
62
+ export function CrossplaneApplyNotice({ resources, unknownResources = [], children }: { resources: any[]; unknownResources?: any[]; children?: ReactNode }) {
63
+ const paused = resources.filter(isAnnotatedCrossplanePause)
64
+ if (paused.length === 0 && unknownResources.length === 0) return null
65
+ return <AlertBanner
66
+ variant="info"
67
+ className="mb-2"
68
+ title={paused.length ? 'Pause affects reconciliation' : 'Existing pause state is unknown'}
69
+ message={<>
70
+ {paused.length > 0 && <>
71
+ <p>{resourceNames(paused)}.</p>
72
+ <p className="mt-1">Kubernetes can accept edits. While <code>crossplane.io/paused</code> remains <code>&quot;true&quot;</code>, {paused.length === 1 ? 'this resource’s controller will' : 'these resources’ controllers will'} defer reconciliation. Removing the annotation requests resumption and may allow pending work, including deletion. Other resources&apos; controllers may continue independently.</p>
73
+ </>}
74
+ {unknownResources.length > 0 && <>
75
+ <p className="mt-1">Live pause state is unverified for {resourceNames(unknownResources)}.</p>
76
+ <p className="mt-1">If <code>crossplane.io/paused</code> remains <code>&quot;true&quot;</code> on an existing resource, its controller defers reconciliation. Omitting the annotation from server-side apply YAML does not guarantee its removal.</p>
77
+ </>}
78
+ {children}
79
+ </>}
80
+ />
81
+ }
82
+
83
+ function yamlObjects(yaml: string): any[] {
84
+ try {
85
+ return parseAllDocuments(yaml).map(document => document.errors.length ? null : document.toJS({ maxAliasCount: 100 }))
86
+ } catch {
87
+ return []
88
+ }
89
+ }
90
+
91
+ export function CrossplaneYamlApplyNotice({ yaml, baselineYaml = '', baselineUnavailable = false, previewDocuments, mode = 'apply', resourceData }: {
92
+ yaml: string
93
+ baselineYaml?: string
94
+ baselineUnavailable?: boolean
95
+ previewDocuments?: YamlPreviewResult[]
96
+ mode?: 'apply' | 'create' | 'update'
97
+ resourceData?: unknown
98
+ }) {
99
+ const context = useMemo(() => {
100
+ const submitted = splitYamlDocuments(yaml).map(document => document.content)
101
+ const original = splitYamlDocuments(baselineYaml).map(document => document.content)
102
+ const matchesTarget = (data: any, target: any) => Boolean(data && target
103
+ && typeof data.apiVersion === 'string' && typeof target.apiVersion === 'string'
104
+ && resourceKey(apiVersionToGroup(data.apiVersion), data.kind, data.metadata?.namespace ?? '', data.metadata?.name)
105
+ === resourceKey(apiVersionToGroup(target.apiVersion), target.kind, target.metadata?.namespace ?? '', target.metadata?.name))
106
+ const pairs = (previewDocuments
107
+ ? previewDocuments.map(preview => ({
108
+ after: yamlObjects(preview.predictedYaml || preview.submittedYaml || submitted[preview.index] || '')[0],
109
+ before: yamlObjects(preview.baselineYaml || original[preview.index] || '')[0],
110
+ preview,
111
+ }))
112
+ : Array.from({ length: Math.max(submitted.length, original.length) }, (_, index) => ({
113
+ after: yamlObjects(submitted[index] || '')[0],
114
+ before: yamlObjects(original[index] || '')[0],
115
+ preview: undefined,
116
+ }))).map(pair => matchesTarget(pair.before, pair.after) ? pair : { ...pair, before: undefined })
117
+ const rawSource = pairs.length === 1 && isAnnotatedCrossplanePause(resourceData) ? resourceData as any : undefined
118
+ const matchesRaw = (data: any) => matchesTarget(data, rawSource)
119
+ const rawPause = matchesRaw(pairs[0]?.after) ? rawSource : undefined
120
+ const hasPause = (data: any) => isCrossplanePaused(data) && (isCrossplaneOperationResource(data) || Boolean(rawPause && matchesRaw(data)))
121
+ const resources = pairs.flatMap(pair => {
122
+ const source = hasPause(pair.after) ? pair.after : hasPause(pair.before) ? pair.before : undefined
123
+ return source ? [isAnnotatedCrossplanePause(source) ? source : rawPause] : []
124
+ })
125
+ const unknownResources = pairs.filter(pair => mode !== 'create' && (baselineUnavailable || pair.preview?.status === 'unavailable' && !pair.preview.predictedYaml && !pair.preview.baselineYaml && !pair.before && pair.preview.action !== 'create'))
126
+ .map(pair => pair.after).filter(data => data && (isCrossplaneOperationResource(data) || Boolean(rawPause && matchesRaw(data))))
127
+ const kept = pairs.filter(pair => pair.preview?.predictedYaml && hasPause(pair.before) && hasPause(pair.after)).map(pair => pair.after)
128
+ const requested = pairs.filter(pair => pair.preview?.predictedYaml && !hasPause(pair.before) && hasPause(pair.after)).map(pair => pair.after)
129
+ const cleared = pairs.filter(pair => pair.preview?.predictedYaml && hasPause(pair.before) && !isCrossplanePaused(pair.after)).map(pair => pair.before)
130
+ const omissionUnverified = pairs.some(pair => !pair.preview?.predictedYaml && hasPause(pair.before) && pair.after?.metadata?.annotations?.['crossplane.io/paused'] === undefined)
131
+ return { resources, unknownResources, kept, requested, cleared, omissionUnverified }
132
+ }, [yaml, baselineYaml, baselineUnavailable, previewDocuments, mode, resourceData])
133
+ return <CrossplaneApplyNotice resources={context.resources} unknownResources={context.unknownResources}>
134
+ {context.kept.length > 0 && <p className="mt-1">Server preview keeps the pause request on {resourceNames(context.kept)}.</p>}
135
+ {context.requested.length > 0 && <p className="mt-1">Server preview requests a pause on {resourceNames(context.requested)}.</p>}
136
+ {context.cleared.length > 0 && <p className="mt-1">Server preview clears the pause request on {resourceNames(context.cleared)}.</p>}
137
+ {context.omissionUnverified && <p className="mt-1">Omitting the annotation from server-side apply YAML does not guarantee its removal.</p>}
138
+ </CrossplaneApplyNotice>
139
+ }