@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.
- package/README.md +21 -0
- package/package.json +1 -1
- package/src/components/applications/ApplicationsView.tsx +8 -7
- package/src/components/cluster-switcher/ClusterSwitcher.tsx +4 -0
- package/src/components/compare/CompareResourcePicker.tsx +18 -4
- package/src/components/compare/ResourceCompareView.tsx +6 -2
- package/src/components/gitops/GitOpsDetailLayout.test.tsx +64 -0
- package/src/components/gitops/GitOpsDetailLayout.tsx +22 -15
- package/src/components/gitops/GitOpsTableView.coverage.test.tsx +27 -0
- package/src/components/gitops/GitOpsTableView.tsx +46 -12
- package/src/components/gitops/detail-helpers.test.ts +58 -13
- package/src/components/gitops/detail-helpers.ts +37 -6
- package/src/components/gitops/index.ts +1 -0
- package/src/components/issues/IssuesView.tsx +16 -11
- package/src/components/issues/diagnostic.ts +2 -0
- package/src/components/issues/severity.ts +1 -0
- package/src/components/logs/LogCore.tsx +13 -11
- package/src/components/logs/LogsViewer.test.tsx +104 -59
- package/src/components/logs/LogsViewer.tsx +28 -8
- package/src/components/logs/WorkloadLogsViewer.tsx +1 -1
- package/src/components/namespace-switcher/NamespacePicker.test.tsx +236 -2
- package/src/components/namespace-switcher/NamespacePicker.tsx +44 -8
- package/src/components/resources/ResourcesSidebar.keyboard.test.tsx +28 -0
- package/src/components/resources/ResourcesSidebar.test.tsx +49 -0
- package/src/components/resources/ResourcesSidebar.tsx +62 -26
- package/src/components/resources/ResourcesView.crossplane-pause.test.tsx +133 -0
- package/src/components/resources/ResourcesView.flagger.test.tsx +31 -0
- package/src/components/resources/ResourcesView.kind-view.test.tsx +96 -1
- package/src/components/resources/ResourcesView.opentelemetry.test.tsx +29 -0
- package/src/components/resources/ResourcesView.selected-kind.test.ts +14 -0
- package/src/components/resources/ResourcesView.trivy-search.test.tsx +59 -0
- package/src/components/resources/ResourcesView.tsx +342 -83
- package/src/components/resources/curated-column-ownership.test.ts +3 -3
- package/src/components/resources/generic-status.ts +15 -0
- package/src/components/resources/printer-columns.test.ts +36 -0
- package/src/components/resources/printer-columns.ts +12 -1
- package/src/components/resources/readiness-gates.test.ts +95 -0
- package/src/components/resources/renderers/CompositeRenderer.tsx +8 -12
- package/src/components/resources/renderers/CrossplanePauseContext.test.tsx +153 -0
- package/src/components/resources/renderers/CrossplanePauseContext.tsx +39 -0
- package/src/components/resources/renderers/FlaggerRenderers.tsx +96 -0
- package/src/components/resources/renderers/GenericRenderer.tsx +19 -11
- package/src/components/resources/renderers/ManagedResourceRenderer.tsx +5 -12
- package/src/components/resources/renderers/OpenTelemetryRenderers.tsx +73 -0
- package/src/components/resources/renderers/PodRenderer.test.tsx +115 -0
- package/src/components/resources/renderers/PodRenderer.tsx +32 -9
- package/src/components/resources/renderers/crossplane-cells.tsx +5 -5
- package/src/components/resources/renderers/index.ts +3 -0
- package/src/components/resources/resource-utils-crossplane.test.ts +45 -0
- package/src/components/resources/resource-utils-crossplane.ts +44 -8
- package/src/components/resources/resource-utils-flagger.test.tsx +82 -0
- package/src/components/resources/resource-utils-flagger.ts +76 -0
- package/src/components/resources/resource-utils-opentelemetry.test.tsx +74 -0
- package/src/components/resources/resource-utils-opentelemetry.ts +97 -0
- package/src/components/resources/resource-utils-trivy.test.ts +87 -0
- package/src/components/resources/resource-utils-trivy.ts +122 -0
- package/src/components/resources/resource-utils.ts +80 -2
- package/src/components/shared/CreateResourceDialog.test.tsx +14 -0
- package/src/components/shared/CreateResourceDialog.tsx +5 -0
- package/src/components/shared/DetailShell.phone.test.tsx +54 -0
- package/src/components/shared/DetailShell.tsx +21 -8
- package/src/components/shared/EditableYamlView.phone.test.tsx +44 -0
- package/src/components/shared/EditableYamlView.test.tsx +58 -0
- package/src/components/shared/EditableYamlView.tsx +18 -3
- package/src/components/shared/FilterRailPanel.test.tsx +39 -0
- package/src/components/shared/FilterRailPanel.tsx +62 -0
- package/src/components/shared/GitOpsWriteWarning.managers.test.tsx +85 -0
- package/src/components/shared/GitOpsWriteWarning.tsx +87 -5
- package/src/components/shared/ManagedByChip.manager.test.tsx +115 -0
- package/src/components/shared/ManagedByChip.menu.test.tsx +103 -0
- package/src/components/shared/ManagedByChip.test.tsx +22 -0
- package/src/components/shared/ManagedByChip.tsx +158 -5
- package/src/components/shared/ResourceActionsBar.identity.test.tsx +64 -0
- package/src/components/shared/ResourceActionsBar.tsx +31 -9
- package/src/components/shared/ResourceRendererDispatch.test.tsx +49 -0
- package/src/components/shared/ResourceRendererDispatch.tsx +69 -31
- package/src/components/shared/SetImageDialog.tsx +3 -0
- package/src/components/timeline/TimelineList.tsx +5 -1
- package/src/components/topology/TopologyControls.tsx +1 -1
- package/src/components/ui/CrossplaneOperationNotice.test.tsx +153 -0
- package/src/components/ui/CrossplaneOperationNotice.tsx +139 -0
- package/src/components/ui/FetchResult.test.tsx +19 -2
- package/src/components/ui/FetchResult.tsx +13 -1
- package/src/components/ui/ForceDeleteConfirmDialog.test.tsx +55 -6
- package/src/components/ui/ForceDeleteConfirmDialog.tsx +127 -22
- package/src/components/ui/MultiSelectPicker.test.tsx +20 -0
- package/src/components/ui/MultiSelectPicker.tsx +20 -2
- package/src/components/ui/PageHeader.tsx +7 -4
- package/src/components/ui/SummaryTile.tsx +13 -3
- package/src/components/ui/Toast.tsx +33 -26
- package/src/components/ui/Tooltip.touch.test.tsx +55 -0
- package/src/components/ui/Tooltip.tsx +11 -2
- package/src/components/ui/YamlReview.test.tsx +52 -0
- package/src/components/ui/YamlReview.tsx +7 -0
- package/src/components/ui/drawer-components.test.tsx +280 -0
- package/src/components/ui/drawer-components.tsx +302 -18
- package/src/components/ui/drawer-older-replicasets.test.tsx +45 -0
- package/src/components/workload/WorkloadView.relationships.test.tsx +73 -0
- package/src/components/workload/WorkloadView.timelineFocus.test.tsx +96 -0
- package/src/components/workload/WorkloadView.tsx +105 -34
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useKeepInViewport.mounted.test.tsx +72 -0
- package/src/hooks/useKeepInViewport.test.ts +27 -0
- package/src/hooks/useKeepInViewport.ts +87 -0
- package/src/hooks/useMediaQuery.ts +21 -0
- package/src/hooks/usePhoneLayout.ts +12 -0
- package/src/theme/tailwind-theme.css +5 -0
- package/src/types/core.ts +119 -3
- package/src/types/gitops-flux-inventory.test.ts +24 -0
- package/src/types/gitops.ts +5 -5
- package/src/types/index.ts +3 -0
- package/src/types/read-coverage.ts +65 -0
- package/src/types/resource-coverage.ts +40 -0
- package/src/utils/api-resources.ts +1 -0
- package/src/utils/format.test.ts +7 -0
- package/src/utils/format.ts +6 -1
- package/src/utils/gitops-owner.test.ts +42 -1
- package/src/utils/gitops-owner.ts +158 -8
- package/src/utils/gitops-write-guard.test.ts +332 -0
- package/src/utils/gitops-write-guard.ts +175 -11
- package/src/utils/index.ts +3 -0
- package/src/utils/read-actions.test.ts +22 -0
- package/src/utils/read-actions.ts +90 -0
- package/src/utils/resource-coverage.test.ts +121 -0
- package/src/utils/resource-coverage.ts +117 -0
- package/src/utils/touch-intent.test.ts +39 -0
- package/src/utils/touch-intent.ts +35 -0
- 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
|
-
|
|
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
|
-
(
|
|
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' &&
|
|
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' &&
|
|
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' &&
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
1202
|
-
if (k === '
|
|
1203
|
-
if (k === '
|
|
1204
|
-
if (k === '
|
|
1205
|
-
if (k === '
|
|
1206
|
-
if (k === '
|
|
1207
|
-
if (k === '
|
|
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
|
|
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' 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>"true"</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' 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>"true"</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
|
+
}
|