From: Aashish Sharma Date: Tue, 7 Jul 2026 08:19:41 +0000 (+0530) Subject: mgr/dashboard: carbonize Setup multi-site replication wizard X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=2f4a86c0a3af232ad6b9c10f0dbad5e9867a8030;p=ceph.git mgr/dashboard: carbonize Setup multi-site replication wizard Fixes: https://tracker.ceph.com/issues/77976 Signed-off-by: Aashish Sharma --- diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/multisite.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/multisite.po.ts index b180cffff06b..41f79c9fc47b 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/multisite.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/rgw/multisite.po.ts @@ -16,8 +16,8 @@ const pages = { }; enum WizardSteps { - CreateRealmZonegroup = 'Create Realm & Zonegroup', - CreateZone = 'Create Zone', + CreateRealmZonegroup = 'Create realm & zonegroup', + CreateZone = 'Create zone', Review = 'Review' } @@ -313,42 +313,52 @@ export class MultisitePageHelper extends PageHelper { @PageHelper.restrictTo(pages.wizard.url) replicationWizardExist() { - cy.get('cds-modal').then(() => { - cy.get('[data-testid=rgw-multisite-wizard-header]').should( - 'contain.text', - 'Set up Multi-site Replication' - ); - }); + cy.get('cds-modal').should('exist'); + cy.get('[data-testid=rgw-multisite-wizard-header]').should( + 'contain.text', + 'Set up Multi-site Replication' + ); } - @PageHelper.restrictTo(pages.index.url) + @PageHelper.restrictTo(pages.wizard.url) verifyWizardContents(step: Step) { - cy.get('cds-modal').then(() => { - this.gotoStep(step); - if (step === 'CreateRealmZonegroup') { - this.typeValueToField('realmName', 'test-realm'); - this.typeValueToField('zonegroupName', 'test-zg'); - } else if (step === 'CreateZone') { - this.typeValueToField('zoneName', 'test-zone'); - } else { - this.gotoStep('Review'); - cy.get('.form-group.row').then(() => { - cy.get('#realmName').invoke('text').should('eq', 'test-realm'); - cy.get('#zonegroupName').invoke('text').should('eq', 'test-zg'); - cy.get('#zoneName').invoke('text').should('eq', 'test-zone'); - }); - } - }); + this.gotoStep(step); + if (step === 'CreateRealmZonegroup') { + this.typeValueToField('realmName', 'test-realm'); + this.typeValueToField('zonegroupName', 'test-zg'); + } else if (step === 'CreateZone') { + this.typeValueToField('zoneName', 'test-zone'); + } else { + // step === 'Review': already navigated by gotoStep above + cy.get('.review-item').should('exist'); + cy.get('.review-item') + .contains('dt', 'Realm name') + .siblings('dd') + .invoke('text') + .invoke('trim') + .should('eq', 'test-realm'); + cy.get('.review-item') + .contains('dt', 'Zonegroup name') + .siblings('dd') + .invoke('text') + .invoke('trim') + .should('eq', 'test-zg'); + cy.get('.review-item') + .contains('dt', 'Zone name') + .siblings('dd') + .invoke('text') + .invoke('trim') + .should('eq', 'test-zone'); + } } typeValueToField(fieldID: string, value: string) { - cy.get(`#${fieldID}`).clear().type(value).should('have.value', value); + cy.get(`#${fieldID}`).should('be.visible').clear().type(value).should('have.value', value); } gotoStep(step: Step) { - cy.get('cd-wizard').then(() => { - cy.get('form').should('be.visible'); - cy.get('button').contains(WizardSteps[step]).click(); - }); + cy.get('cd-rgw-multisite-wizard').should('exist'); + cy.get('cds-progress-indicator').should('be.visible'); + cy.get('cds-progress-indicator').contains('button', WizardSteps[step]).click(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts index 6482c02a6285..91476f6b0511 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/hosts/host-form/host-form.component.ts @@ -72,7 +72,7 @@ export class HostFormComponent extends CdForm implements OnInit { this.hostService.getLabels().subscribe((resp: string[]) => { const uniqueLabels = new Set(resp.concat(this.hostService.predefinedLabels)); this.labelsOption = Array.from(uniqueLabels).map((label) => { - return { name: label, content: label }; + return { name: label, content: label, selected: false }; }); }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts index ef80a1ca4cbd..e6fd70b271e4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -1463,7 +1463,13 @@ export class ServiceFormComponent extends CdForm implements OnInit { } enableRgwModule(): void { - this.mgrModuleService.updateModuleState('rgw', false, null, '', $localize`Enabled RGW Module`); + this.mgrModuleService.updateModuleState( + 'rgw', + false, + undefined, + '', + $localize`Enabled RGW Module` + ); } prePopulateId() { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-flow-modal/rgw-multisite-sync-flow-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-flow-modal/rgw-multisite-sync-flow-modal.component.ts index c36dadc796e6..5fd728ef2a04 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-flow-modal/rgw-multisite-sync-flow-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-flow-modal/rgw-multisite-sync-flow-modal.component.ts @@ -90,17 +90,18 @@ export class RgwMultisiteSyncFlowModalComponent extends BaseModal implements OnI zones.push(new SelectOption(false, zone.name, '')); }); this.zones = [...zones].map((zone: { name: string }) => { - return { name: zone.name, content: zone.name }; + return { name: zone.name, content: zone.name, selected: false }; }); if (this.editing) { // @TODO: Editing/deletion of directional flow not supported yet. // Integrate it once the backend supports it. if (this.groupType === FlowType.symmetrical) { this.zones = [...zones].map((zone: { name: string }) => { - if (this.flowSelectedRow.zones.includes(zone.name)) { - return { name: zone.name, content: zone.name, selected: true }; - } - return { name: zone.name, content: zone.name }; + return { + name: zone.name, + content: zone.name, + selected: this.flowSelectedRow.zones.includes(zone.name) + }; }); this.currentFormGroupContext.patchValue({ zones: this.zones }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-pipe-modal/rgw-multisite-sync-pipe-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-pipe-modal/rgw-multisite-sync-pipe-modal.component.ts index cc70c9644d75..d7e9d424391a 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-pipe-modal/rgw-multisite-sync-pipe-modal.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-sync-pipe-modal/rgw-multisite-sync-pipe-modal.component.ts @@ -111,24 +111,26 @@ export class RgwMultisiteSyncPipeModalComponent extends BaseModal implements OnI zones.push(new SelectOption(false, zone.name, '')); }); this.sourceZones = JSON.parse(JSON.stringify(zones)).map((zone: { name: string }) => { - return { name: zone.name, content: zone.name }; + return { name: zone.name, content: zone.name, selected: false }; }); this.destZones = JSON.parse(JSON.stringify(zones)).map((zone: { name: string }) => { - return { name: zone.name, content: zone.name }; + return { name: zone.name, content: zone.name, selected: false }; }); if (this.editing) { this.pipeForm.get('pipe_id').disable(); this.sourceZones = [...this.sourceZones].map((zone: { name: string }) => { - if (this.pipeSelectedRow.source.zones.includes(zone.name)) { - return { name: zone.name, content: zone.name, selected: true }; - } - return { name: zone.name, content: zone.name }; + return { + name: zone.name, + content: zone.name, + selected: this.pipeSelectedRow.source.zones.includes(zone.name) + }; }); this.destZones = [...this.destZones].map((zone: { name: string }) => { - if (this.pipeSelectedRow.dest.zones.includes(zone.name)) { - return { name: zone.name, content: zone.name, selected: true }; - } - return { name: zone.name, content: zone.name }; + return { + name: zone.name, + content: zone.name, + selected: this.pipeSelectedRow.dest.zones.includes(zone.name) + }; }); const availableDestZone: SelectOption[] = []; this.pipeSelectedRow.dest.zones.forEach((zone: string) => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/multisite-wizard-steps.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/multisite-wizard-steps.enum.ts index d6437a3fa313..7593b1e596fd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/multisite-wizard-steps.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/multisite-wizard-steps.enum.ts @@ -1,7 +1,7 @@ export enum StepTitles { - CreateRealmAndZonegroup = 'Create Realm & Zonegroup', - CreateZone = 'Create Zone', - SelectCluster = 'Select Cluster', + CreateRealmAndZonegroup = 'Create realm & zonegroup', + CreateZone = 'Create zone', + SelectCluster = 'Select cluster', Review = 'Review' } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.html index 9443b1eca337..2469a2691722 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.html @@ -1,626 +1,711 @@ - - -

+ +
- Set up Multi-site Replication -

-
- -
-
-
- -
+ This wizard enables you to set up multi-site replication within your Ceph environment. If + you have already added another cluster to your multi-cluster setup, you can select that + cluster in the wizard to automate the replication process. If no additional cluster is + currently added, the wizard will guide you through creating the necessary realm, zonegroup, + and zone, and provide a realm token. This token can be used later to manually import into a + desired cluster to establish replication between the clusters. + -
- - -
+ @if (showConfigType && isMultiClusterConfigured) { +
+ + Create new realm/zonegroup/zone + Select existing realm - +
+ } + + + @if ( + multisiteSetupForm.get('configType').value === 'existingRealm' && + showConfigType && + isMultiClusterConfigured + ) { +
+ + @for (realm of realmList; track realm) { + + } + +
+ } + + + @if (multisiteSetupForm.get('configType').value === 'newRealm' || !showConfigType) { + +
+ Realm name + + + + @if (multisiteSetupForm.showError('realmName', formDir, 'required')) { + This field is required. - This wizard enables you to set up multi-site replication within your Ceph - environment.If you have already added another cluster to your multi-cluster setup, - you can select that cluster in the wizard to automate the replication process.If no - additional cluster is currently added, the wizard will guide you through creating - the necessary realm, zonegroup, and zone, and provide a realm token.This token can - be used later to manually import into a desired cluster to establish replication - between the clusters. - -
This realm name is already in use. Choose a unique name. -
+ + +
+ Zonegroup name + + + + @if (multisiteSetupForm.showError('zonegroupName', formDir, 'required')) { + This field is required. + } + @if (multisiteSetupForm.showError('zonegroupName', formDir, 'uniqueName')) { + This zonegroup name is already in use. Choose a unique name. + } + +
+ + +
+ + + + + This field is required. + + Select the endpoints for the Zonegroup. Endpoints are the URLs or IP addresses from + which the rgw gateways in that zonegroup can be accessed. You can select multiple + endpoints in case you have multiple rgw gateways in a zonegroup. +
+ } + + + + + +
+ @if (multisiteSetupForm.get('configType').value === 'newRealm') { +
+ +
+ Zone name + + + + @if (multisiteSetupForm.showError('zoneName', formDir, 'required')) { + Realm configuration modeThis field is required. -
- - -
-
- - -
-
-
- This zone name is already in use. Choose a unique name. -
- -
-
-
-
- -
- - - Enter a unique name for the Realm. The Realm is a logical grouping of all - your Zonegroups. - - This field is required. - This realm name is already in use. Choose a unique name. -
-
-
- -
- - - Enter a name for the Zonegroup. Zonegroup will help you identify and manage - the group of zones. - - This field is required. - This zonegroup name is already in use. Choose a unique name. -
-
-
- -
- - - - Select the endpoints for the Zonegroup. Endpoints are the URLs or IP - addresses from which the rgw gateways in that zonegroup can be accessed. You - can select multiple endpoints in case you have multiple rgw gateways in a - zonegroup - -
-
-
-
- -
-
- -
- - - Enter a unique name for the Zone. A Zone represents a distinct data center - or geographical location within a Zonegroup. - - This field is required. - This zone name is already in use. Choose a unique name. -
-
-
- -
- - - Select the endpoints for the Zone. Endpoints are the URLs or IP addresses - from which the rgw gateways in that zone can be accessed. You can select - multiple endpoints in case you have multiple rgw gateways in a zone - -
-
-
- -
- - - Specify the username for the system user. - - - This user will be created automatically as part of the process, and it will - have the necessary permissions to manage and synchronize resources across - zones. - - This field is required. - This username is already in use. Choose a unique name. -
-
-
-
- -
-
- -
- - - -
- - - - - -
- -
-
+ + + + + This field is required. + + Select the endpoints for the Zone. Endpoints are the URLs or IP addresses from which + the rgw gateways in that zone can be accessed. You can select multiple endpoints in + case you have multiple rgw gateways in a zone. +
+ + +
+ Username + - - - - - -
-
- -
- This field is required. -
- - - Choose the cluster where you want to apply this multisite configuration. The - selected cluster will integrate the defined Realm, Zonegroup, and Zones, - enabling data synchronization and management across the multisite setup. - - - Before submitting this form, please verify that the selected cluster has an - active RGW (Rados Gateway) service running. - -
-
-
- This username is already in use. Choose a unique name. -
- - - Replication zone represents the zone to be created in the replication cluster - where your data will be replicated. - - This field is required. -
-
-
-
- - - - Enable archival storage to keep all object versions and protect data from - deletion or corruption. - -
-
+ }
-
-
- - - - - -
-
- -
-
-
-
- - - -
+
+ } + @if ( + isMultiClusterConfigured && multisiteSetupForm.get('configType').value === 'existingRealm' + ) { +
+ +
+ + @for (cluster_detail of clusterDetailsArray; track cluster_detail.name) { + + } + + + @if ( + multisiteSetupForm.controls.cluster.invalid && + multisiteSetupForm.controls.cluster.dirty + ) { + This field is required. + } + +
+ +
+ Replication zone name + + + + @if ( + multisiteSetupForm.controls.replicationZoneName.invalid && + multisiteSetupForm.controls.replicationZoneName.dirty + ) { + This field is required. + } + +
+ +
+ Archive + + Enable archival storage to keep all object versions and protect data from + deletion or corruption. + + +
+
+ } + + + + + @if (stepTitles.length > 3) { + +
- - - - + + Select a cluster to automatically configure replication now, or skip this step to get a + realm token you can use to set up replication manually later. + + +
+ + @for (cluster_detail of clusterDetailsArray; track cluster_detail.name) { + + } + + + @if ( + multisiteSetupForm.controls.cluster.invalid && + multisiteSetupForm.controls.cluster.dirty + ) { + This field is required. + } + +
+ +
+ Replication zone name + + + + @if ( + multisiteSetupForm.controls.replicationZoneName.invalid && + multisiteSetupForm.controls.replicationZoneName.dirty + ) { + This field is required. + } + +
+ +
+ Archive + + Enable archival storage to keep all object versions and protect data from deletion + or corruption. + + +
+ +
+ +
+
+
+ } - - - - - - - + + + @if (!loading) { + @if (!setupCompleted) { + + } @else { + + } + } @else { + + } + + - - - - -
-
+ - Multi-site replication setup is complete. -
+
- - - + + @if (isMultiClusterConfigured && !stepsToSkip['Select cluster']) { +
+
+
+ Realm +
+
{{ completionSummary?.realm }}
+
+
+
+ Secondary cluster +
+
+ {{ completionSummary?.secondaryCluster }} +
+
+
+
+ Secondary zone +
+
+ {{ completionSummary?.secondaryZone }} +
+
+
+ } @else { + + }
@for (realminfo of realms; track realminfo; let i = $index) { -
- - Realm Name - - -
- -
- - Token - - - - -
- +
+
+
+ Realm +
+
{{ realminfo.realm }}
+
+
+
+ Realm token +
+
+ + + +
+
+
+ + @if (i < realms.length - 1) {
} } + + This token is required to establish replication with another cluster. Store it securely until + it has been imported. +
During the automation process, the RGW module will be enabled on both the source and target @@ -628,124 +713,137 @@ seconds) on each cluster. - - - - - - - - - - - - - -
- - Realm Name: - -
- {{ multisiteSetupForm.get('realmName').value }} -
-
-
- - Zonegroup Name: - -
- {{ multisiteSetupForm.get('zonegroupName').value }} -
-
-
- - Zonegroup Endpoints: - -
- {{ rgwEndpoints.value.join(', ') }} -
-
-
- - Zone Name: - -
- {{ multisiteSetupForm.get('zoneName').value }} -
-
-
- - Zone Endpoints: - -
- {{ rgwEndpoints.value.join(', ') }} -
-
-
- - Username: - -
- {{ multisiteSetupForm.get('username').value }} -
-
-
- - -
- - Selected Realm: - -
- {{ multisiteSetupForm.get('selectedRealm').value }} -
-
-
- -
-
- - Selected Replication Cluster: - -
- {{ selectedCluster }} + + @if (multisiteSetupForm.get('configType').value === 'newRealm') { +
+
+
+ Realm name +
+
+ {{ multisiteSetupForm.get('realmName').value }} +
-
-
- - Replication Zone Name: - -
- {{ multisiteSetupForm.get('replicationZoneName').value }} +
+
+ Zonegroup name +
+
+ {{ multisiteSetupForm.get('zonegroupName').value }} +
-
-
+
+
+ Zonegroup endpoints +
+
+ {{ (multisiteSetupForm.get('zonegroup_endpoints').value || []).join(', ') }} +
+
+
+
+ Zone name +
+
+ {{ multisiteSetupForm.get('zoneName').value }} +
+
+
+
+ Zone endpoints +
+
+ {{ (multisiteSetupForm.get('zone_endpoints').value || []).join(', ') }} +
+
+
+
+ Username +
+
+ {{ multisiteSetupForm.get('username').value }} +
+
+ @if (isMultiClusterConfigured && !stepsToSkip['Select cluster']) { +
+
+ Selected replication cluster +
+
{{ selectedCluster }}
+
+
+
+ Replication zone name +
+
+ {{ multisiteSetupForm.get('replicationZoneName').value }} +
+
+ } + + } + + + @if (multisiteSetupForm.get('configType').value === 'existingRealm') { +
+
+
+ Selected realm +
+
+ {{ multisiteSetupForm.get('selectedRealm').value }} +
+
+ @if (isMultiClusterConfigured && !stepsToSkip['Select cluster']) { +
+
+ Selected replication cluster +
+
{{ selectedCluster }}
+
+
+
+ Replication zone name +
+
+ {{ multisiteSetupForm.get('replicationZoneName').value }} +
+
+ } +
+ } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.scss index bd53b868bcfd..5c64c18e77bf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.scss @@ -1,3 +1,47 @@ cds-loading { - margin-left: 0.5rem; + margin-left: var(--cds-spacing-03); +} + +.review-list { + margin: 0; + padding: 0; + list-style: none; +} + +.review-item { + display: flex; + flex-direction: column; + margin-bottom: var(--cds-spacing-05); + padding-bottom: var(--cds-spacing-05); + border-bottom: 1px solid var(--cds-border-subtle); +} + +.review-list-end .review-item:last-child { + border-bottom: none; + margin-bottom: 0; +} + +.review-label { + margin-bottom: var(--cds-spacing-02); + font-weight: 400; + color: var(--cds-text-secondary); + letter-spacing: var(--cds-label-01-letter-spacing, 0.32px); + font-size: var(--cds-label-01-font-size, 0.75rem); +} + +.review-value { + margin: 0; + padding: 0; + font-weight: 400; + color: var(--cds-text-primary); +} + +.skip-step { + margin-top: var(--cds-spacing-06); + padding-top: var(--cds-spacing-05); + border-top: 1px solid var(--cds-border-subtle); + + button { + padding-left: 0; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.spec.ts index 1844d136970b..7f2635b86446 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.spec.ts @@ -5,28 +5,683 @@ import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { SharedModule } from '~/app/shared/shared.module'; import { ReactiveFormsModule } from '@angular/forms'; - import { RouterTestingModule } from '@angular/router/testing'; import { CUSTOM_ELEMENTS_SCHEMA, NO_ERRORS_SCHEMA } from '@angular/core'; +import { of, Subject } from 'rxjs'; + +import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; +import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service'; +import { RgwRealmService } from '~/app/shared/api/rgw-realm.service'; +import { MultiClusterService } from '~/app/shared/api/multi-cluster.service'; +import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { + STEP_TITLES_EXISTING_REALM, + STEP_TITLES_MULTI_CLUSTER_CONFIGURED, + STEP_TITLES_SINGLE_CLUSTER +} from './multisite-wizard-steps.enum'; + +/** Minimal MultiClusterConfig response with no remote clusters. */ +const SINGLE_CLUSTER_CONFIG = { + current_url: 'http://localhost', + current_user: 'admin', + hub_url: '', + config: { + local: [{ url: 'http://localhost', name: 'local', cluster_alias: 'Local', user: 'admin' }] + } +}; + +/** MultiClusterConfig response with one remote cluster. */ +const MULTI_CLUSTER_CONFIG = { + current_url: 'http://localhost', + current_user: 'admin', + hub_url: '', + config: { + local: [{ url: 'http://localhost', name: 'local', cluster_alias: 'Local', user: 'admin' }], + remote: [{ url: 'http://remote', name: 'remote-fsid', cluster_alias: 'Remote', user: 'admin' }] + } +}; + +const EMPTY_REALMS = { default_info: '', realms: [] }; +const SOME_REALMS = { default_info: 'realm-a', realms: ['realm-a', 'realm-b'] }; + +function buildDaemon(id = 'rgw.1', hostname = 'host1', port = 8080) { + return { id, server_hostname: hostname, port }; +} describe('RgwMultisiteWizardComponent', () => { let component: RgwMultisiteWizardComponent; let fixture: ComponentFixture; + let daemonSpy: jasmine.Spy; + let daemonGetSpy: jasmine.Spy; + let multiClusterSpy: jasmine.Spy; + let realmListSpy: jasmine.Spy; + let rgwModuleStatusSpy: jasmine.Spy; + let setupReplicationSpy: jasmine.Spy; + let summarySubscribeSpy: jasmine.Spy; + let mgrModuleUpdateCompleted$: Subject; + beforeEach(async () => { + mgrModuleUpdateCompleted$ = new Subject(); + + daemonSpy = jasmine.createSpy('list').and.returnValue(of([buildDaemon()])); + daemonGetSpy = jasmine + .createSpy('get') + .and.returnValue(of({ rgw_metadata: { 'frontend_config#0': 'port=8080' } })); + multiClusterSpy = jasmine.createSpy('getCluster').and.returnValue(of(SINGLE_CLUSTER_CONFIG)); + realmListSpy = jasmine.createSpy('list').and.returnValue(of(EMPTY_REALMS)); + rgwModuleStatusSpy = jasmine.createSpy('getRgwModuleStatus').and.returnValue(of(true)); + setupReplicationSpy = jasmine.createSpy('setUpMultisiteReplication').and.returnValue(of([])); + summarySubscribeSpy = jasmine.createSpy('subscribe').and.returnValue({ unsubscribe: () => {} }); + await TestBed.configureTestingModule({ declarations: [RgwMultisiteWizardComponent], imports: [HttpClientTestingModule, SharedModule, ReactiveFormsModule, RouterTestingModule], schemas: [NO_ERRORS_SCHEMA, CUSTOM_ELEMENTS_SCHEMA], - providers: [NgbActiveModal] + providers: [ + NgbActiveModal, + { + provide: RgwDaemonService, + useValue: { list: daemonSpy, get: daemonGetSpy } + }, + { + provide: MultiClusterService, + useValue: { getCluster: multiClusterSpy } + }, + { + provide: RgwRealmService, + useValue: { list: realmListSpy } + }, + { + provide: RgwMultisiteService, + useValue: { + setUpMultisiteReplication: setupReplicationSpy, + setRestartGatewayMessage: jasmine.createSpy('setRestartGatewayMessage'), + getRgwModuleStatus: rgwModuleStatusSpy + } + }, + { + provide: MgrModuleService, + useValue: { + updateCompleted$: mgrModuleUpdateCompleted$, + updateModuleState: jasmine.createSpy('updateModuleState'), + list: jasmine.createSpy('list').and.returnValue(of([])) + } + }, + { + provide: SummaryService, + useValue: { subscribe: summarySubscribeSpy } + }, + { + provide: NotificationService, + useValue: { show: jasmine.createSpy('show') } + } + ] }).compileComponents(); fixture = TestBed.createComponent(RgwMultisiteWizardComponent); component = fixture.componentInstance; - fixture.detectChanges(); + // Stub cdr.detectChanges() before ngOnInit runs. ChangeDetectorRef is a + // view-level token that cannot be overridden in the root providers array; + // spying on the instance is the correct approach. This prevents the + // component's internal cdr.detectChanges() calls from triggering template + // rendering, which would fail on Carbon custom-element value accessors + // (NG01203 / DynamicInputComboboxDirective teardown crash). + spyOn(component['cdr'], 'detectChanges'); + component.ngOnInit(); }); + // ─── basic ─────────────────────────────────────────────────────────────────── + it('should create', () => { expect(component).toBeTruthy(); }); + + // ─── createForm ────────────────────────────────────────────────────────────── + + describe('createForm', () => { + it('initialises all expected form controls', () => { + const controls = [ + 'realmName', + 'zonegroupName', + 'zonegroup_endpoints', + 'zoneName', + 'zone_endpoints', + 'username', + 'cluster', + 'replicationZoneName', + 'configType', + 'selectedRealm', + 'secondary_archive_zone' + ]; + controls.forEach((name) => expect(component.multisiteSetupForm.get(name)).not.toBeNull()); + }); + + it('sets sensible default values', () => { + const f = component.multisiteSetupForm; + expect(f.get('realmName').value).toBe('default_realm'); + expect(f.get('zonegroupName').value).toBe('default_zonegroup'); + expect(f.get('zoneName').value).toBe('default_zone'); + expect(f.get('username').value).toBe('default_system_user'); + expect(f.get('replicationZoneName').value).toBe('new_replicated_zone'); + expect(f.get('configType').value).toBe('newRealm'); + expect(f.get('secondary_archive_zone').value).toBe(false); + }); + }); + + // ─── ngOnInit — single cluster ─────────────────────────────────────────────── + + describe('ngOnInit — single cluster', () => { + it('sets isMultiClusterConfigured to false when no remote clusters', () => { + expect(component.isMultiClusterConfigured).toBe(false); + }); + + it('uses STEP_TITLES_SINGLE_CLUSTER step titles', () => { + const labels = component.stepTitles.map((s) => s.label); + expect(labels).toEqual(STEP_TITLES_SINGLE_CLUSTER); + }); + + it('clears validators on cluster and replicationZoneName controls', () => { + expect(component.multisiteSetupForm.get('cluster').validator).toBeNull(); + expect(component.multisiteSetupForm.get('replicationZoneName').validator).toBeNull(); + }); + + it('does not show configType radio when there are no realms', () => { + expect(component.showConfigType).toBe(false); + }); + }); + + // ─── ngOnInit — multi cluster ──────────────────────────────────────────────── + + describe('ngOnInit — multi cluster', () => { + beforeEach(() => { + multiClusterSpy.and.returnValue(of(MULTI_CLUSTER_CONFIG)); + component.ngOnInit(); + }); + + it('sets isMultiClusterConfigured to true', () => { + expect(component.isMultiClusterConfigured).toBe(true); + }); + + it('uses STEP_TITLES_MULTI_CLUSTER_CONFIGURED step titles', () => { + const labels = component.stepTitles.map((s) => s.label); + expect(labels).toEqual(STEP_TITLES_MULTI_CLUSTER_CONFIGURED); + }); + + it('pre-selects the first remote cluster', () => { + expect(component.selectedCluster).toBe('remote-fsid'); + }); + + it('populates clusterDetailsArray with remote clusters only', () => { + expect(component.clusterDetailsArray.length).toBe(1); + expect(component.clusterDetailsArray[0].name).toBe('remote-fsid'); + }); + }); + + // ─── ngOnInit — realm list ──────────────────────────────────────────────────── + + describe('ngOnInit — realm list', () => { + it('leaves showConfigType false when realm list is empty', () => { + expect(component.showConfigType).toBe(false); + }); + + it('sets showConfigType true and pre-selects first realm when realms exist', () => { + realmListSpy.and.returnValue(of(SOME_REALMS)); + multiClusterSpy.and.returnValue(of(MULTI_CLUSTER_CONFIG)); + component.ngOnInit(); + + expect(component.showConfigType).toBe(true); + expect(component.realmList).toEqual(['realm-a', 'realm-b']); + expect(component.multisiteSetupForm.get('selectedRealm').value).toBe('realm-a'); + }); + }); + + // ─── selectedCluster sync via valueChanges ─────────────────────────────────── + + describe('selectedCluster sync', () => { + it('updates selectedCluster when cluster form control value changes', () => { + component.multisiteSetupForm.get('cluster').setValue('new-fsid'); + expect(component.selectedCluster).toBe('new-fsid'); + }); + }); + + // ─── RGW endpoint loading ───────────────────────────────────────────────────── + + describe('loadRGWEndpoints', () => { + it('calls RgwDaemonService.list on init', () => { + expect(daemonSpy).toHaveBeenCalled(); + }); + + it('builds HTTP endpoint strings from daemon stats', () => { + expect(component.rgwEndpoints.value).toEqual(['http://host1:8080']); + }); + + it('marks endpoint as HTTPS when frontend_config contains ssl_port', () => { + daemonGetSpy.and.returnValue(of({ rgw_metadata: { 'frontend_config#0': 'ssl_port=8443' } })); + component['loadRGWEndpoints'](); + expect(component.rgwEndpoints.value).toEqual(['https://host1:8080']); + }); + + it('sets zonegroup_endpoints and zone_endpoints form controls', () => { + expect(component.multisiteSetupForm.get('zonegroup_endpoints').value).toEqual([ + 'http://host1:8080' + ]); + expect(component.multisiteSetupForm.get('zone_endpoints').value).toEqual([ + 'http://host1:8080' + ]); + }); + + it('resets endpoint controls to null when an empty array is set', () => { + component.multisiteSetupForm.get('zonegroup_endpoints').setValue([]); + expect(component.multisiteSetupForm.get('zonegroup_endpoints').value).toBeNull(); + }); + }); + + // ─── step validity ──────────────────────────────────────────────────────────── + + describe('step0Valid', () => { + it('is true when configType is newRealm and required fields are filled', () => { + component.multisiteSetupForm.patchValue({ + realmName: 'r', + zonegroupName: 'zg', + zonegroup_endpoints: ['http://host:8080'] + }); + expect(component.step0Valid).toBe(true); + }); + + it('is false when realmName is empty and configType is newRealm', () => { + component.multisiteSetupForm.get('realmName').setValue(''); + expect(component.step0Valid).toBe(false); + }); + + it('is true when configType is existingRealm and selectedRealm is set', () => { + component.multisiteSetupForm.patchValue({ + configType: 'existingRealm', + selectedRealm: 'realm-a' + }); + expect(component.step0Valid).toBe(true); + }); + + it('is true when configType is existingRealm and selectedRealm is null (no validator on selectedRealm)', () => { + component.multisiteSetupForm.patchValue({ + configType: 'existingRealm', + selectedRealm: null + }); + // selectedRealm has no Validators.required, so the control is always + // valid and step0Valid returns true regardless of the value. + expect(component.step0Valid).toBe(true); + }); + }); + + describe('step1Valid', () => { + it('is true when configType is newRealm and zone fields are valid', () => { + component.multisiteSetupForm.patchValue({ + zoneName: 'zone1', + zone_endpoints: ['http://host:8080'], + username: 'user1' + }); + expect(component.step1Valid).toBe(true); + }); + + it('is false when zoneName is empty', () => { + component.multisiteSetupForm.get('zoneName').setValue(''); + expect(component.step1Valid).toBe(false); + }); + + it('is true when configType is existingRealm and cluster + replicationZoneName valid', () => { + component.multisiteSetupForm.patchValue({ + configType: 'existingRealm', + cluster: 'remote-fsid', + replicationZoneName: 'rep-zone' + }); + expect(component.step1Valid).toBe(true); + }); + }); + + describe('step2Valid', () => { + it('is true when cluster and replicationZoneName have values', () => { + component.multisiteSetupForm.patchValue({ + cluster: 'remote-fsid', + replicationZoneName: 'rep-zone' + }); + expect(component.step2Valid).toBe(true); + }); + + it('is false when cluster is null and the validator is active', () => { + // The single-cluster init path clears cluster validators; restore them + // here to match the multi-cluster scenario where step2 is reachable. + const { Validators } = require('@angular/forms'); + component.multisiteSetupForm.get('cluster').setValidators([Validators.required]); + component.multisiteSetupForm.get('cluster').setValue(null); + component.multisiteSetupForm.get('cluster').updateValueAndValidity(); + expect(component.step2Valid).toBe(false); + }); + }); + + // ─── markStep*Touched ───────────────────────────────────────────────────────── + + describe('markStep0Touched', () => { + it('marks realmName, zonegroupName, zonegroup_endpoints for newRealm', () => { + component.multisiteSetupForm.get('configType').setValue('newRealm'); + component.markStep0Touched(); + ['realmName', 'zonegroupName', 'zonegroup_endpoints'].forEach((n) => { + expect(component.multisiteSetupForm.get(n).touched).toBe(true); + expect(component.multisiteSetupForm.get(n).dirty).toBe(true); + }); + }); + + it('marks selectedRealm for existingRealm', () => { + component.multisiteSetupForm.get('configType').setValue('existingRealm'); + component.markStep0Touched(); + expect(component.multisiteSetupForm.get('selectedRealm').touched).toBe(true); + }); + }); + + describe('markStep1Touched', () => { + it('marks zoneName, zone_endpoints, username for newRealm', () => { + component.multisiteSetupForm.get('configType').setValue('newRealm'); + component.markStep1Touched(); + ['zoneName', 'zone_endpoints', 'username'].forEach((n) => { + expect(component.multisiteSetupForm.get(n).touched).toBe(true); + }); + }); + + it('marks cluster, replicationZoneName for existingRealm', () => { + component.multisiteSetupForm.get('configType').setValue('existingRealm'); + component.markStep1Touched(); + ['cluster', 'replicationZoneName'].forEach((n) => { + expect(component.multisiteSetupForm.get(n).touched).toBe(true); + }); + }); + }); + + // ─── showSubmitButtonLabel ──────────────────────────────────────────────────── + + describe('showSubmitButtonLabel', () => { + it('returns Close when setup is completed', () => { + component.setupCompleted = true; + expect(component.showSubmitButtonLabel()).toBe('Close'); + }); + + it('returns Configure Multi-Site when multi-cluster configured and not skipped', () => { + component.setupCompleted = false; + component.isMultiClusterConfigured = true; + component.stepsToSkip['Select cluster'] = false; + expect(component.showSubmitButtonLabel()).toBe('Configure Multi-Site'); + }); + + it('returns Export Multi-Site token when single cluster', () => { + component.setupCompleted = false; + component.isMultiClusterConfigured = false; + expect(component.showSubmitButtonLabel()).toBe('Export Multi-Site token'); + }); + + it('returns Export Multi-Site token when Select cluster is skipped', () => { + component.setupCompleted = false; + component.isMultiClusterConfigured = true; + component.stepsToSkip['Select cluster'] = true; + expect(component.showSubmitButtonLabel()).toBe('Export Multi-Site token'); + }); + }); + + // ─── skipSelectCluster ──────────────────────────────────────────────────────── + + describe('skipSelectCluster', () => { + it('marks Select cluster as skipped', () => { + component.skipSelectCluster(); + expect(component.stepsToSkip['Select cluster']).toBe(true); + }); + + it('clears validators on cluster and replicationZoneName', () => { + // Set validators first so we can verify they are cleared + component.multisiteSetupForm.get('cluster').setValidators(() => ({ required: true })); + component.skipSelectCluster(); + expect(component.multisiteSetupForm.get('cluster').validator).toBeNull(); + expect(component.multisiteSetupForm.get('replicationZoneName').validator).toBeNull(); + }); + }); + + // ─── onStepChanged ──────────────────────────────────────────────────────────── + + describe('onStepChanged', () => { + beforeEach(() => { + multiClusterSpy.and.returnValue(of(MULTI_CLUSTER_CONFIG)); + component.ngOnInit(); + }); + + it('restores validators when navigating back to Select cluster step after skip', () => { + component.skipSelectCluster(); + const selectClusterIndex = component.stepTitles.findIndex( + (s) => s.label === 'Select cluster' + ); + component.onStepChanged({ current: selectClusterIndex }); + + expect(component.stepsToSkip['Select cluster']).toBe(false); + expect(component.multisiteSetupForm.get('cluster').validator).not.toBeNull(); + expect(component.multisiteSetupForm.get('replicationZoneName').validator).not.toBeNull(); + }); + + it('does nothing when navigating to a step that is not Select cluster', () => { + component.skipSelectCluster(); + component.onStepChanged({ current: 0 }); + expect(component.stepsToSkip['Select cluster']).toBe(true); + }); + }); + + // ─── onValidateStep ─────────────────────────────────────────────────────────── + + describe('onValidateStep', () => { + it('calls markStep0Touched for step 0', () => { + spyOn(component, 'markStep0Touched'); + component.onValidateStep({ step: 0 }); + expect(component.markStep0Touched).toHaveBeenCalled(); + }); + + it('calls markStep1Touched for step 1', () => { + spyOn(component, 'markStep1Touched'); + component.onValidateStep({ step: 1 }); + expect(component.markStep1Touched).toHaveBeenCalled(); + }); + + it('calls markStep2Touched for step 2 when it is not the last step', () => { + spyOn(component, 'markStep2Touched'); + // Simulate 4-step wizard (multi-cluster configured) by setting stepTitles directly + component.stepTitles = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((label) => ({ label })); + component.onValidateStep({ step: 2 }); + expect(component.markStep2Touched).toHaveBeenCalled(); + }); + + it('does not call markStep2Touched when step 2 is the last step', () => { + spyOn(component, 'markStep2Touched'); + // 3-step wizard (single cluster), step 2 IS the last step + component.onValidateStep({ step: 2 }); + expect(component.markStep2Touched).not.toHaveBeenCalled(); + }); + }); + + // ─── onConfigTypeChange ─────────────────────────────────────────────────────── + + describe('onConfigTypeChange', () => { + it('switches to STEP_TITLES_EXISTING_REALM when existingRealm selected', () => { + component.onConfigTypeChange({ value: 'existingRealm' }); + expect(component.stepTitles.map((s) => s.label)).toEqual(STEP_TITLES_EXISTING_REALM); + }); + + it('switches to STEP_TITLES_MULTI_CLUSTER_CONFIGURED when newRealm and multi-cluster', () => { + component.isMultiClusterConfigured = true; + component.onConfigTypeChange({ value: 'newRealm' }); + expect(component.stepTitles.map((s) => s.label)).toEqual( + STEP_TITLES_MULTI_CLUSTER_CONFIGURED + ); + }); + + it('switches to STEP_TITLES_SINGLE_CLUSTER when newRealm and single-cluster', () => { + component.isMultiClusterConfigured = false; + component.onConfigTypeChange({ value: 'newRealm' }); + expect(component.stepTitles.map((s) => s.label)).toEqual(STEP_TITLES_SINGLE_CLUSTER); + }); + + it('reads configType from form when no event value is passed', () => { + component.multisiteSetupForm.get('configType').setValue('existingRealm'); + component.onConfigTypeChange(); + expect(component.stepTitles.map((s) => s.label)).toEqual(STEP_TITLES_EXISTING_REALM); + }); + }); + + // ─── onSubmit — single cluster ─────────────────────────────────────────────── + + describe('onSubmit — single cluster (export token)', () => { + beforeEach(() => { + // Ensure form is valid + component.multisiteSetupForm.patchValue({ + realmName: 'my-realm', + zonegroupName: 'my-zg', + zonegroup_endpoints: ['http://host:8080'], + zoneName: 'my-zone', + zone_endpoints: ['http://host:8080'], + username: 'admin' + }); + component.isMultiClusterConfigured = false; + }); + + it('calls setUpMultisiteReplication with correct arguments', () => { + component.onSubmit(); + expect(setupReplicationSpy).toHaveBeenCalledWith( + 'my-realm', + 'my-zg', + 'http://host:8080', + 'my-zone', + 'http://host:8080', + 'admin' + ); + }); + + it('sets setupCompleted to true on success', () => { + component.onSubmit(); + expect(component.setupCompleted).toBe(true); + }); + + it('sets loading to false on success', () => { + component.onSubmit(); + expect(component.loading).toBe(false); + }); + + it('closes the tearsheet when setupCompleted is already true', () => { + component.tearsheet = { closeTearsheet: jest.fn() } as any; + component.setupCompleted = true; + component.onSubmit(); + expect(component.tearsheet.closeTearsheet).toHaveBeenCalled(); + expect(setupReplicationSpy).not.toHaveBeenCalled(); + }); + + it('does not submit when form is invalid', () => { + component.multisiteSetupForm.get('realmName').setValue(''); + component.onSubmit(); + expect(setupReplicationSpy).not.toHaveBeenCalled(); + }); + }); + + // ─── onSubmit — multi cluster ───────────────────────────────────────────────── + + describe('onSubmit — multi-cluster (configure replication)', () => { + const remoteCluster = { + url: 'http://remote', + name: 'remote-fsid', + cluster_alias: 'Remote', + user: 'admin', + token: '', + cluster_connection_status: 0, + ssl_verify: false, + ssl_certificate: '', + ttl: 0 + }; + + beforeEach(() => { + component.isMultiClusterConfigured = true; + component.clusterDetailsArray = [remoteCluster]; + component.stepsToSkip['Select cluster'] = false; + component.multisiteSetupForm.patchValue({ + realmName: 'r', + zonegroupName: 'zg', + zonegroup_endpoints: ['http://host:8080'], + zoneName: 'z', + zone_endpoints: ['http://host:8080'], + username: 'user', + cluster: 'remote-fsid', + replicationZoneName: 'rep-zone' + }); + }); + + it('calls setUpMultisiteReplication with cluster and replicationZoneName', () => { + component.onSubmit(); + expect(setupReplicationSpy).toHaveBeenCalledWith( + 'r', + 'zg', + 'http://host:8080', + 'z', + 'http://host:8080', + 'user', + 'remote-fsid', + 'rep-zone', + '', + [remoteCluster], + '' + ); + }); + + it('sets completionSummary on success', () => { + component.onSubmit(); + expect(component.completionSummary).toEqual({ + realm: 'r', + secondaryCluster: 'Remote - remote-fsid', + secondaryZone: 'rep-zone' + }); + }); + + it('passes archive tier type when secondary_archive_zone is checked', () => { + component.multisiteSetupForm.get('secondary_archive_zone').setValue(true); + component.onSubmit(); + expect(setupReplicationSpy.calls.mostRecent().args[8]).toBe('archive'); + }); + + it('sets form cdSubmitButton error on failure', () => { + setupReplicationSpy.and.returnValue(new (require('rxjs').throwError)('error')); + component.onSubmit(); + expect(component.multisiteSetupForm.errors).toEqual({ cdSubmitButton: true }); + }); + }); + + // ─── onSubmit — RGW module disabled ────────────────────────────────────────── + + describe('onSubmit — RGW module disabled', () => { + let mgrService: MgrModuleService; + + beforeEach(() => { + mgrService = TestBed.inject(MgrModuleService); + component.rgwModuleStatus = false; + component.isMultiClusterConfigured = false; + component.multisiteSetupForm.patchValue({ + realmName: 'r', + zonegroupName: 'zg', + zonegroup_endpoints: ['http://host:8080'], + zoneName: 'z', + zone_endpoints: ['http://host:8080'], + username: 'user' + }); + }); + + it('calls updateModuleState before proceeding', () => { + component.onSubmit(); + expect(mgrService.updateModuleState).toHaveBeenCalled(); + }); + + it('calls setUpMultisiteReplication after updateCompleted$ emits', () => { + component.onSubmit(); + expect(setupReplicationSpy).not.toHaveBeenCalled(); + mgrModuleUpdateCompleted$.next(); + expect(setupReplicationSpy).toHaveBeenCalled(); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.ts index fd6ec99edd78..cf24448a0810 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.ts @@ -1,25 +1,19 @@ -import { ChangeDetectorRef, Component, NgZone, OnInit } from '@angular/core'; +import { ChangeDetectorRef, Component, NgZone, OnInit, ViewChild } from '@angular/core'; import { Location } from '@angular/common'; import { UntypedFormControl, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { Observable, Subscription, forkJoin } from 'rxjs'; -import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { Observable, forkJoin } from 'rxjs'; import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; -import { WizardStepModel } from '~/app/shared/models/wizard-steps'; -import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service'; import { RgwDaemon } from '../models/rgw-daemon'; import { MultiClusterService } from '~/app/shared/api/multi-cluster.service'; import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service'; -import { Icons } from '~/app/shared/enum/icons.enum'; -import { SelectOption } from '~/app/shared/components/select/select-option.model'; -import _ from 'lodash'; -import { SelectMessages } from '~/app/shared/components/select/select-messages.model'; +import { ComboBoxItem } from '~/app/shared/models/combo-box.model'; import { NotificationType } from '~/app/shared/enum/notification-type.enum'; import { NotificationService } from '~/app/shared/services/notification.service'; -import { ActivatedRoute } from '@angular/router'; import { map, switchMap } from 'rxjs/operators'; -import { BaseModal, Step } from 'carbon-components-angular'; +import { Step } from 'carbon-components-angular'; +import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; import { SummaryService } from '~/app/shared/services/summary.service'; import { ExecutingTask } from '~/app/shared/models/executing-task'; import { @@ -64,11 +58,11 @@ enum ConfigType { styleUrls: ['./rgw-multisite-wizard.component.scss'], standalone: false }) -export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { - multisiteSetupForm: CdFormGroup; - currentStep: WizardStepModel; - currentStepSub: Subscription; - permissions: Permissions; +export class RgwMultisiteWizardComponent implements OnInit { + @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent; + + multisiteSetupForm!: CdFormGroup; + permissions!: Permissions; stepTitles: Step[] = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((title) => ({ label: title })); @@ -77,79 +71,80 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { selectedCluster = ''; clusterDetailsArray: MultiCluster[] = []; isMultiClusterConfigured = false; - exportTokenForm: CdFormGroup; - realms: any; + exportTokenForm!: CdFormGroup; + realms: Array<{ realm: string; token: string }> = []; loading = false; - pageURL: string; - icons = Icons; - rgwEndpoints: { value: any[]; options: any[]; messages: any }; - executingTask: ExecutingTask; + zonegroupEndpointsOptions: ComboBoxItem[] = []; + zoneEndpointsOptions: ComboBoxItem[] = []; + rgwEndpoints: { value: any[] }; + executingTask: ExecutingTask | undefined; setupCompleted = false; showConfigType = false; realmList: string[] = []; - rgwModuleStatus: boolean; + rgwModuleStatus!: boolean; + title = $localize`Set up Multi-site Replication`; + description = $localize`Configure realm, zonegroup, and zone for multi-site replication across clusters.`; + completionSummary: { + realm: string; + secondaryCluster: string; + secondaryZone: string; + } | null = null; constructor( - private wizardStepsService: WizardStepsService, public activeModal: NgbActiveModal, - public actionLabels: ActionLabelsI18n, private rgwDaemonService: RgwDaemonService, private multiClusterService: MultiClusterService, private rgwMultisiteService: RgwMultisiteService, private rgwRealmService: RgwRealmService, public notificationService: NotificationService, - private route: ActivatedRoute, private summaryService: SummaryService, private location: Location, private cdr: ChangeDetectorRef, private mgrModuleService: MgrModuleService, private zone: NgZone ) { - super(); - this.pageURL = 'rgw/multisite/configuration'; - this.currentStepSub = this.wizardStepsService - .getCurrentStep() - .subscribe((step: WizardStepModel) => { - this.currentStep = step; - }); - this.currentStep.stepIndex = 0; this.createForm(); - this.rgwEndpoints = { - value: [], - options: [], - messages: new SelectMessages({ - empty: $localize`There are no endpoints.`, - filter: $localize`Select endpoints` - }) - }; + this.rgwEndpoints = { value: [] }; } ngOnInit(): void { - this.open = this.route.outlet === 'modal'; this.loadRGWEndpoints(); - this.multiClusterService.getCluster().subscribe((clusters: MultiClusterConfig) => { - const currentUrl = clusters['current_url']; - this.clusterDetailsArray = Object.values(clusters['config']) - .flat() - .filter((cluster) => cluster['url'] !== currentUrl); - this.isMultiClusterConfigured = this.clusterDetailsArray.length > 0; - this.stepTitles = ( - this.isMultiClusterConfigured - ? STEP_TITLES_MULTI_CLUSTER_CONFIGURED - : STEP_TITLES_SINGLE_CLUSTER - ).map((label, index) => ({ - label, - onClick: () => (this.currentStep.stepIndex = index) - })); - this.wizardStepsService.setTotalSteps(this.stepTitles.length); - this.selectedCluster = this.isMultiClusterConfigured - ? this.clusterDetailsArray[0]['name'] - : null; + (this.multiClusterService.getCluster() as Observable).subscribe( + (clusters: MultiClusterConfig) => { + const currentUrl = clusters['current_url']; + this.clusterDetailsArray = Object.values(clusters['config']) + .flat() + .filter((cluster) => cluster['url'] !== currentUrl); + this.isMultiClusterConfigured = this.clusterDetailsArray.length > 0; + this.stepTitles = ( + this.isMultiClusterConfigured + ? STEP_TITLES_MULTI_CLUSTER_CONFIGURED + : STEP_TITLES_SINGLE_CLUSTER + ).map((label) => ({ label })); + this.selectedCluster = this.isMultiClusterConfigured + ? this.clusterDetailsArray[0]['name'] + : ''; + + if (this.isMultiClusterConfigured) { + this.multisiteSetupForm.get('cluster').setValue(this.clusterDetailsArray[0]['name']); + } + + if (!this.isMultiClusterConfigured) { + this.multisiteSetupForm.get('cluster').clearValidators(); + this.multisiteSetupForm.get('cluster').updateValueAndValidity(); + this.multisiteSetupForm.get('replicationZoneName').clearValidators(); + this.multisiteSetupForm.get('replicationZoneName').updateValueAndValidity(); + } + } + ); + + this.multisiteSetupForm.get('cluster').valueChanges.subscribe((v: string) => { + this.selectedCluster = v; }); this.summaryService.subscribe((summary) => { this.zone.run(() => { - this.executingTask = summary.executing_tasks.find((task) => + this.executingTask = summary?.executing_tasks?.find((task) => task.name.includes('progress/Multisite-Setup') ); this.cdr.detectChanges(); @@ -160,7 +155,7 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { this.stepsToSkip[step.label] = false; }); - this.rgwRealmService.list().subscribe((realmsInfo: RealmsInfo) => { + (this.rgwRealmService.list() as Observable).subscribe((realmsInfo: RealmsInfo) => { this.realmList = realmsInfo?.realms || []; this.showConfigType = this.realmList.length > 0; if (this.showConfigType) { @@ -206,9 +201,25 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { const protocol = stats.frontendConfig.includes('ssl_port') ? Protocol.HTTPS : Protocol.HTTP; return `${protocol}://${stats.hostname}:${stats.port}`; }); - this.rgwEndpoints.options = this.rgwEndpoints.value.map( - (endpoint) => new SelectOption(false, endpoint, '') - ); + const makeOptions = () => + this.rgwEndpoints.value.map((endpoint) => ({ + content: endpoint, + name: endpoint, + selected: true + })); + this.zonegroupEndpointsOptions = makeOptions(); + this.zoneEndpointsOptions = makeOptions(); + const selectedEndpoints = this.rgwEndpoints.value; + this.multisiteSetupForm.get('zonegroup_endpoints').setValue(selectedEndpoints); + this.multisiteSetupForm.get('zone_endpoints').setValue(selectedEndpoints); + ['zonegroup_endpoints', 'zone_endpoints'].forEach((controlName) => { + this.multisiteSetupForm.get(controlName).valueChanges.subscribe((val: string[]) => { + if (Array.isArray(val) && val.length === 0) { + this.multisiteSetupForm.get(controlName).setValue(null, { emitEvent: false }); + } + }); + }); + this.cdr.detectChanges(); } @@ -246,47 +257,116 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { } } - showSubmitButtonLabel() { - if (this.wizardStepsService.isLastStep()) { - if (!this.setupCompleted) { - if (this.isMultiClusterConfigured) { - return $localize`Configure Multi-Site`; - } else { - return $localize`Export Multi-Site token`; - } - } else { - return $localize`Close`; - } - } else { - return $localize`Next`; + get step0Valid(): boolean { + const f = this.multisiteSetupForm; + if (f.get('configType').value === 'existingRealm') { + return f.get('selectedRealm').valid; } + return ( + f.get('realmName').valid && f.get('zonegroupName').valid && f.get('zonegroup_endpoints').valid + ); } - showCancelButtonLabel() { - return !this.wizardStepsService.isFirstStep() - ? this.actionLabels.BACK - : this.actionLabels.CANCEL; + get step1Valid(): boolean { + const f = this.multisiteSetupForm; + if (f.get('configType').value === 'existingRealm') { + return f.get('cluster').valid && f.get('replicationZoneName').valid; + } + return f.get('zoneName').valid && f.get('zone_endpoints').valid && f.get('username').valid; } - onNextStep() { - if (!this.wizardStepsService.isLastStep()) { - this.wizardStepsService.moveToNextStep(); + get step2Valid(): boolean { + const f = this.multisiteSetupForm; + return f.get('cluster').valid && f.get('replicationZoneName').valid; + } + + markStep0Touched(): void { + const f = this.multisiteSetupForm; + if (f.get('configType').value === 'existingRealm') { + ['selectedRealm'].forEach((n) => { + f.get(n).markAsTouched(); + f.get(n).markAsDirty(); + }); } else { - if (this.setupCompleted) { - this.closeModal(); - } else { - this.onSubmit(); - } + ['realmName', 'zonegroupName', 'zonegroup_endpoints'].forEach((n) => { + f.get(n).markAsTouched(); + f.get(n).markAsDirty(); + }); } - this.wizardStepsService.getCurrentStep().subscribe((step: WizardStepModel) => { - this.currentStep = step; - if (this.currentStep.stepIndex === 2 && this.isMultiClusterConfigured) { - this.stepsToSkip['Select Cluster'] = false; - } + } + + markStep1Touched(): void { + const f = this.multisiteSetupForm; + if (f.get('configType').value === 'existingRealm') { + ['cluster', 'replicationZoneName'].forEach((n) => { + f.get(n).markAsTouched(); + f.get(n).markAsDirty(); + }); + } else { + ['zoneName', 'zone_endpoints', 'username'].forEach((n) => { + f.get(n).markAsTouched(); + f.get(n).markAsDirty(); + }); + } + } + + markStep2Touched(): void { + const f = this.multisiteSetupForm; + ['cluster', 'replicationZoneName'].forEach((n) => { + f.get(n).markAsTouched(); + f.get(n).markAsDirty(); }); } + showSubmitButtonLabel() { + if (this.setupCompleted) { + return $localize`Close`; + } + if (this.isMultiClusterConfigured && !this.stepsToSkip['Select cluster']) { + return $localize`Configure Multi-Site`; + } + return $localize`Export Multi-Site token`; + } + + skipSelectCluster(): void { + this.stepsToSkip['Select cluster'] = true; + this.multisiteSetupForm.get('cluster').clearValidators(); + this.multisiteSetupForm.get('cluster').updateValueAndValidity(); + this.multisiteSetupForm.get('replicationZoneName').clearValidators(); + this.multisiteSetupForm.get('replicationZoneName').updateValueAndValidity(); + this.tearsheet?.onNext(); + } + + onValidateStep(event: { step: number }): void { + const lastStep = this.stepTitles.length - 1; + if (event.step === 0) this.markStep0Touched(); + else if (event.step === 1) this.markStep1Touched(); + else if (event.step === 2 && event.step !== lastStep) this.markStep2Touched(); + } + + onStepChanged(event: { current: number }): void { + const selectClusterIndex = this.stepTitles.findIndex((s) => s.label === 'Select cluster'); + if (event.current === selectClusterIndex && this.stepsToSkip['Select cluster']) { + this.stepsToSkip['Select cluster'] = false; + this.multisiteSetupForm.get('cluster').setValidators([Validators.required]); + this.multisiteSetupForm.get('cluster').updateValueAndValidity(); + this.multisiteSetupForm.get('replicationZoneName').setValidators([Validators.required]); + this.multisiteSetupForm.get('replicationZoneName').updateValueAndValidity(); + } + } + onSubmit() { + if (this.setupCompleted) { + this.tearsheet.closeTearsheet(); + return; + } + + this.multisiteSetupForm.markAllAsTouched(); + Object.values(this.multisiteSetupForm.controls).forEach((c) => c.markAsDirty()); + if (this.multisiteSetupForm.invalid) { + return; + } + this.loading = true; const proceedWithSetup = () => { @@ -294,11 +374,15 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { const values = this.multisiteSetupForm.getRawValue(); const realmName = values['realmName']; const zonegroupName = values['zonegroupName']; - const zonegroupEndpoints = this.rgwEndpoints.value.join(','); + const zonegroupEndpoints = (values['zonegroup_endpoints'] as string[] | string | null) + ? [].concat(values['zonegroup_endpoints']).join(',') + : ''; const zoneName = values['zoneName']; - const zoneEndpoints = this.rgwEndpoints.value.join(','); + const zoneEndpoints = (values['zone_endpoints'] as string[] | string | null) + ? [].concat(values['zone_endpoints']).join(',') + : ''; const username = values['username']; - if (!this.isMultiClusterConfigured || this.stepsToSkip['Select Cluster']) { + if (!this.isMultiClusterConfigured || this.stepsToSkip['Select cluster']) { this.rgwMultisiteService .setUpMultisiteReplication( realmName, @@ -312,7 +396,11 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { this.setupCompleted = true; this.rgwMultisiteService.setRestartGatewayMessage(false); this.loading = false; - this.realms = data; + this.realms = (Array.isArray(data) ? data : []).filter( + (r: object) => r['realm'] === realmName + ); + this.title = $localize`Multi-site replication created`; + this.description = $localize`The replication configuration has been created successfully.`; this.showSuccessNotification(); }); } else { @@ -344,6 +432,21 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { this.setupCompleted = true; this.rgwMultisiteService.setRestartGatewayMessage(false); this.loading = false; + const selectedClusterDetail = this.clusterDetailsArray.find( + (c) => c['name'] === values['cluster'] + ); + this.completionSummary = { + realm: + this.multisiteSetupForm.get('configType').value === 'existingRealm' + ? values['selectedRealm'] + : realmName, + secondaryCluster: selectedClusterDetail + ? `${selectedClusterDetail['cluster_alias']} - ${selectedClusterDetail['name']}` + : values['cluster'], + secondaryZone: values['replicationZoneName'] + }; + this.title = $localize`Multi-site replication created`; + this.description = $localize`The replication configuration has been created successfully.`; this.showSuccessNotification(); }, () => { @@ -357,7 +460,7 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { this.mgrModuleService.updateModuleState( 'rgw', false, - null, + undefined, '', '', false, @@ -379,42 +482,18 @@ export class RgwMultisiteWizardComponent extends BaseModal implements OnInit { ); } - onPreviousStep() { - if (!this.wizardStepsService.isFirstStep()) { - this.wizardStepsService.moveToPreviousStep(); - } else { - this.location.back(); - } - } - - onSkip() { - const stepTitle = this.stepTitles[this.currentStep.stepIndex]; - this.stepsToSkip[stepTitle.label] = true; - this.onNextStep(); - } - closeModal(): void { this.location.back(); } - onConfigTypeChange() { - const configType = this.multisiteSetupForm.get('configType')?.value; + onConfigTypeChange(event?: { value: string }) { + const configType = event?.value ?? this.multisiteSetupForm.get('configType')?.value; if (configType === ConfigType.ExistingRealm) { - this.stepTitles = STEP_TITLES_EXISTING_REALM.map((title) => ({ - label: title - })); - this.stepTitles.forEach((steps, index) => { - steps.onClick = () => (this.currentStep.stepIndex = index); - }); + this.stepTitles = STEP_TITLES_EXISTING_REALM.map((title) => ({ label: title })); } else if (this.isMultiClusterConfigured) { - this.stepTitles = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((title) => ({ - label: title - })); + this.stepTitles = STEP_TITLES_MULTI_CLUSTER_CONFIGURED.map((title) => ({ label: title })); } else { - this.stepTitles = STEP_TITLES_SINGLE_CLUSTER.map((title) => ({ - label: title - })); + this.stepTitles = STEP_TITLES_SINGLE_CLUSTER.map((title) => ({ label: title })); } - this.wizardStepsService.setTotalSteps(this.stepTitles.length); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index dfdd0e68fc3e..7b74bb573806 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -70,6 +70,7 @@ import { LoadingModule, ModalModule, ProgressIndicatorModule, + ProgressBarModule, CodeSnippetModule, InputModule, CheckboxModule, @@ -150,6 +151,7 @@ import { RgwAccountRoleFormComponent } from './rgw-account-role-form/rgw-account ModalModule, GridModule, ProgressIndicatorModule, + ProgressBarModule, CodeSnippetModule, ButtonModule, LoadingModule, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts index bfe36c73f97c..2279757ed8f7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/error/error.component.ts @@ -110,6 +110,6 @@ export class ErrorComponent implements OnDestroy, OnInit { } enableModule(): void { - this.mgrModuleService.updateModuleState(this.module_name, false, null, this.navigateTo); + this.mgrModuleService.updateModuleState(this.module_name, false, undefined, this.navigateTo); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts index 53a508b01f3b..cc847bb7e7df 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts @@ -93,7 +93,7 @@ export class MgrModuleService { updateModuleState( module: string, enabled: boolean = false, - table: TableComponent = null, + table: TableComponent | undefined = undefined, navigateTo: string = '', notificationText?: string, navigateByUrl?: boolean, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts index 2ddcc4abcd1a..e7b9fe0ffdc6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts @@ -1,4 +1,13 @@ -import { Component, ContentChild, TemplateRef, ViewChild } from '@angular/core'; +import { + Component, + ContentChild, + Input, + OnChanges, + SimpleChanges, + TemplateRef, + ViewChild +} from '@angular/core'; +import { Subject } from 'rxjs'; import { TearsheetStep } from '../../models/tearsheet-step'; @Component({ @@ -7,13 +16,33 @@ import { TearsheetStep } from '../../models/tearsheet-step'; templateUrl: './tearsheet-step.component.html', styleUrls: ['./tearsheet-step.component.scss'] }) -export class TearsheetStepComponent { +export class TearsheetStepComponent implements OnChanges { @ViewChild(TemplateRef, { static: true }) template!: TemplateRef; @ContentChild('tearsheetStep') stepComponent!: TearsheetStep; + @Input() stepValid: boolean | null = null; + + readonly validityChange$ = new Subject(); + + ngOnChanges(changes: SimpleChanges): void { + if ('stepValid' in changes) { + this.validityChange$.next(this.canProceed); + } + } + + get resolvedFormGroup() { + return this.stepComponent?.formGroup ?? null; + } + + get canProceed(): boolean { + if (this.stepValid !== null) return this.stepValid; + if (this.resolvedFormGroup) return this.resolvedFormGroup.valid; + return true; + } + get rightInfluencer(): TemplateRef | null { return this.stepComponent?.rightInfluencer ?? null; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html index 11e86406a674..2fcfc1a6369d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html @@ -13,7 +13,16 @@ >
-

+

+ @if (successIcon) { + + } {{ title }}

@@ -27,23 +36,27 @@ [fullWidth]="true" > -

- - -
+ + +
+ }
@@ -61,16 +74,19 @@ class="tearsheet-footer-cancel" (click)="closeTearsheet()" size="xl" - i18n > - Cancel + @if (isSubmitLoading) { + Close + } @else { + Cancel + } @if (steps.length > 1) { @if (steps.length > 1) {