]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add a wizard to setup rgw multisite replication 57715/head
authorAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Tue, 7 May 2024 03:47:24 +0000 (09:17 +0530)
committerAashish Sharma <aasharma@li-e74156cc-2f67-11b2-a85c-e98659a63c5c.ibm.com>
Mon, 15 Jul 2024 05:15:51 +0000 (10:45 +0530)
Fixes: https://tracker.ceph.com/issues/66227
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
13 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-details/rgw-multisite-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-details/rgw-multisite-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-details/rgw-multisite-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-multisite-wizard/rgw-multisite-wizard.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-multisite.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/back-button/back-button.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/wizard/wizard.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts

index 921a2dfe3eb7d1e803228fd09466b9010c76b0a3..0acf38b345348dc9432697ff49808eea00165b60 100644 (file)
        i18n>Configuration</a>
     <ng-template ngbNavContent>
       <div>
-        <cd-alert-panel
-          *ngIf="!rgwModuleStatus"
-          type="info"
-          spacingClass="mb-3"
-          class="d-flex align-items-center"
-          i18n
-          >In order to access the import/export feature, the rgw module must be enabled
-
+        <cd-alert-panel *ngIf="!rgwModuleStatus"
+                        type="info"
+                        spacingClass="mb-3"
+                        class="d-flex align-items-center"
+                        i18n>In order to access the import/export feature, the rgw module must be enabled
           <button class="btn btn-light mx-2"
                   type="button"
-                  (click)="enableRgwModule()">
-            Enable
-          </button>
+                  (click)="enableRgwModule()">Enable</button>
         </cd-alert-panel>
-        <cd-alert-panel
-          *ngIf="restartGatewayMessage"
-          type="warning"
-          spacingClass="mb-3"
-          i18n>Please restart all Ceph Object Gateway instances in all zones to ensure consistent
-          multisite configuration updates.
+        <cd-alert-panel   *ngIf="restartGatewayMessage"
+                          type="warning"
+                          spacingClass="mb-3"
+                          i18n>Please restart all Ceph Object Gateway instances in all zones to ensure consistent multisite configuration updates.
           <a class="text-decoration-underline"
-             routerLink="/services"> Cluster->Services</a>
+             routerLink="/services">
+             Cluster->Services</a>
         </cd-alert-panel>
-        <cd-table-actions
-          class="btn-group mb-4 me-2"
-          [permission]="permission"
-          [selection]="selection"
-          [tableActions]="createTableActions"
-        >
-        </cd-table-actions>
-        <span *ngIf="showMigrateAction">
-          <cd-table-actions
-            class="btn-group mb-4 me-2 secondary"
-            [permission]="permission"
-            [btnColor]="'light'"
-            [selection]="selection"
-            [tableActions]="migrateTableAction"
-          >
+        <span *ngIf="!showMigrateAndReplicationActions; else migrateAndReplicationActionTpl">
+          <cd-table-actions class="btn-group mb-4 me-2"
+                            [permission]="permission"
+                            [selection]="selection"
+                            [tableActions]="createTableActions">
           </cd-table-actions>
         </span>
-        <cd-table-actions
-          class="btn-group mb-4 me-2"
-          [permission]="permission"
-          [btnColor]="'light'"
-          [selection]="selection"
-          [tableActions]="importAction"
-        >
+        <ng-template #migrateAndReplicationActionTpl>
+          <cd-table-actions class="btn-group mb-4 me-2"
+                            [permission]="permission"
+                            [selection]="selection"
+                            [tableActions]="multisiteReplicationActions">
+          </cd-table-actions>
+          <cd-table-actions class="btn-group mb-4 me-2 secondary"
+                            [permission]="permission"
+                            [btnColor]="'light'"
+                            [selection]="selection"
+                            [tableActions]="migrateTableAction">
+          </cd-table-actions>
+        </ng-template>
+        <cd-table-actions class="btn-group mb-4 me-2"
+                          [permission]="permission"
+                          [btnColor]="'light'"
+                          [selection]="selection"
+                          [tableActions]="importAction">
         </cd-table-actions>
-        <cd-table-actions
-          class="btn-group mb-4 me-2"
-          [permission]="permission"
-          [btnColor]="'light'"
-          [selection]="selection"
-          [tableActions]="exportAction">
+        <cd-table-actions class="btn-group mb-4 me-2"
+                          [permission]="permission"
+                          [btnColor]="'light'"
+                          [selection]="selection"
+                          [tableActions]="exportAction">
         </cd-table-actions>
       </div>
       <div class="card">
         <div class="card-header"
-             i18n>Topology Viewer</div>
-        <div class="card-body">
-          <div class="row">
-            <div class="col-sm-6 col-lg-6 tree-container">
-              <i *ngIf="loadingIndicator"
-                 [ngClass]="[icons.large, icons.spinner, icons.spin]"></i>
-              <tree-root
-                #tree
-                [nodes]="nodes"
-                [options]="treeOptions"
-                (updateData)="onUpdateData()">
-                <ng-template
-                  #treeNodeTemplate
-                  let-node>
-                  <span *ngIf="node.data.name"
-                        class="me-3">
-                    <span *ngIf="node.data.show_warning">
-                      <i
-                        class="text-danger"
+             i18n>Topology Viewer
+        </div>
+        <div class="row">
+          <div class="col-sm-6 col-lg-6 tree-container">
+            <i *ngIf="loadingIndicator"
+               [ngClass]="[icons.large, icons.spinner, icons.spin]"></i>
+            <tree-root #tree
+                       [nodes]="nodes"
+                       [options]="treeOptions"
+                       (updateData)="onUpdateData()">
+              <ng-template #treeNodeTemplate
+                           let-node>
+                <span *ngIf="node.data.name"
+                      class="me-3">
+                  <span *ngIf="(node.data.show_warning)">
+                    <i  class="text-danger"
                         i18n-title
                         [title]="node.data.warning_message"
-                        [ngClass]="icons.danger"
-                      ></i>
-                    </span>
-                    <i [ngClass]="node.data.icon"></i>
-                    {{ node.data.name }}
+                        [ngClass]="icons.danger"></i>
                   </span>
-                  <span class="badge badge-success me-2"
-                        *ngIf="node.data.is_default">
+                  <i [ngClass]="node.data.icon"></i>
+                  {{ node.data.name }}
+                </span>
+                <span class="badge badge-success me-2"
+                      *ngIf="node.data.is_default">
                     default
-                  </span>
-                  <span class="badge badge-warning me-2"
-                        *ngIf="node.data.is_master"> master </span>
-                  <span class="badge badge-warning me-2"
-                        *ngIf="node.data.secondary_zone">
-                    secondary-zone
-                  </span>
-                  <div class="btn-group align-inline-btns"
-                       *ngIf="node.isFocused"
-                       role="group">
-                    <div [title]="editTitle"
-                         i18n-title>
-                      <button
-                        type="button"
-                        class="btn btn-light dropdown-toggle-split ms-1"
-                        (click)="openModal(node, true)"
-                        [disabled]="getDisable() || node.data.secondary_zone">
-                        <i [ngClass]="[icons.edit]"></i>
-                      </button>
-                    </div>
-                    <div [title]="deleteTitle"
-                         i18n-title>
-                      <button
-                        type="button"
-                        class="btn btn-light ms-1"
-                        [disabled]="isDeleteDisabled(node) || node.data.secondary_zone"
-                        (click)="delete(node)">
-                        <i [ngClass]="[icons.destroy]"></i>
-                      </button>
-                    </div>
+                </span>
+                <span class="badge badge-warning me-2"
+                      *ngIf="node.data.is_master"> master </span>
+                <span class="badge badge-warning me-2"
+                      *ngIf="node.data.secondary_zone">
+                  secondary-zone
+                </span>
+                <div class="btn-group align-inline-btns"
+                     *ngIf="node.isFocused"
+                     role="group">
+                  <div [title]="editTitle"
+                       i18n-title>
+                    <button type="button"
+                            class="btn btn-light dropdown-toggle-split ms-1"
+                            (click)="openModal(node, true)"
+                            [disabled]="getDisable() || node.data.secondary_zone">
+                      <i [ngClass]="[icons.edit]"></i>
+                    </button>
                   </div>
-                </ng-template>
-              </tree-root>
-            </div>
-            <div class="col-sm-6 col-lg-6 metadata"
-                 *ngIf="metadata">
-              <legend>{{ metadataTitle }}</legend>
-              <div>
-                <cd-table-key-value
-                cdTableDetail
-                [data]="metadata"></cd-table-key-value>
-              </div>
+                  <div [title]="deleteTitle"
+                       i18n-title>
+                    <button type="button"
+                            class="btn btn-light ms-1"
+                            [disabled]="isDeleteDisabled(node) || node.data.secondary_zone"
+                            (click)="delete(node)">
+                      <i [ngClass]="[icons.destroy]"></i>
+                    </button>
+                  </div>
+                </div>
+              </ng-template>
+            </tree-root>
+          </div>
+          <div class="col-sm-6 col-lg-6 metadata"
+               *ngIf="metadata">
+            <legend>{{ metadataTitle }}</legend>
+            <div>
+              <cd-table-key-value cdTableDetail
+                                  [data]="metadata">
+              </cd-table-key-value>
             </div>
           </div>
         </div>
 </nav>
 
 <div [ngbNavOutlet]="nav"></div>
+<router-outlet name="modal"></router-outlet>
index ef833a0324ce8f6e528a3ba20500a02eceb9d3cd..bf36bee1d82e17cb15d693d691eeef5a0cd1475a 100644 (file)
@@ -8,7 +8,7 @@ import { SharedModule } from '~/app/shared/shared.module';
 import { RgwMultisiteDetailsComponent } from './rgw-multisite-details.component';
 import { RouterTestingModule } from '@angular/router/testing';
 import { configureTestBed } from '~/testing/unit-test-helper';
-import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+import { NgbNavModule, NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
 describe('RgwMultisiteDetailsComponent', () => {
   let component: RgwMultisiteDetailsComponent;
@@ -24,7 +24,8 @@ describe('RgwMultisiteDetailsComponent', () => {
       ToastrModule.forRoot(),
       RouterTestingModule,
       NgbNavModule
-    ]
+    ],
+    providers: [NgbActiveModal]
   });
 
   beforeEach(() => {
@@ -40,6 +41,6 @@ describe('RgwMultisiteDetailsComponent', () => {
 
   it('should display right title', () => {
     const span = debugElement.nativeElement.querySelector('.card-header');
-    expect(span.textContent).toBe('Topology Viewer');
+    expect(span.textContent.trim()).toBe('Topology Viewer');
   });
 });
index 4b65b7e37bd4573cb38112ffaf05585269949e3e..1c7ab210d6b5d6f786758ae4ba46ed2ca92c90f0 100644 (file)
@@ -6,7 +6,7 @@ import {
   TreeNode,
   TREE_ACTIONS
 } from '@circlon/angular-tree-component';
-import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { NgbActiveModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
 import _ from 'lodash';
 
 import { forkJoin, Subscription, timer as observableTimer } from 'rxjs';
@@ -37,6 +37,9 @@ import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
 import { MgrModuleService } from '~/app/shared/api/mgr-module.service';
 import { BlockUI, NgBlockUI } from 'ng-block-ui';
 import { Router } from '@angular/router';
+import { RgwMultisiteWizardComponent } from '../rgw-multisite-wizard/rgw-multisite-wizard.component';
+
+const BASE_URL = 'rgw/multisite';
 
 @Component({
   selector: 'cd-rgw-multisite-details',
@@ -65,6 +68,7 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
   migrateTableAction: CdTableAction[];
   importAction: CdTableAction[];
   exportAction: CdTableAction[];
+  multisiteReplicationActions: CdTableAction[];
   loadingIndicator = true;
   nodes: object[] = [];
   treeOptions: ITreeOptions = {
@@ -92,7 +96,7 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
   defaultZoneId = '';
   multisiteInfo: object[] = [];
   defaultsInfo: string[] = [];
-  showMigrateAction: boolean = false;
+  showMigrateAndReplicationActions = false;
   editTitle: string = 'Edit';
   deleteTitle: string = 'Delete';
   disableExport = true;
@@ -102,6 +106,7 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
   activeId: string;
 
   constructor(
+    public activeModal: NgbActiveModal,
     private modalService: ModalService,
     private timerService: TimerService,
     private authStorageService: AuthStorageService,
@@ -147,6 +152,12 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
     }
   }
 
+  openMultisiteSetupWizard() {
+    this.bsModalRef = this.modalService.show(RgwMultisiteWizardComponent, {
+      size: 'lg'
+    });
+  }
+
   openMigrateModal() {
     const initialState = {
       multisiteInfo: this.multisiteInfo
@@ -206,49 +217,62 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
   }
 
   ngOnInit() {
-    const createRealmAction: CdTableAction = {
-      permission: 'create',
-      icon: Icons.add,
-      name: this.actionLabels.CREATE + ' Realm',
-      click: () => this.openModal('realm')
-    };
-    const createZonegroupAction: CdTableAction = {
-      permission: 'create',
-      icon: Icons.add,
-      name: this.actionLabels.CREATE + ' Zone Group',
-      click: () => this.openModal('zonegroup'),
-      disable: () => this.getDisable()
-    };
-    const createZoneAction: CdTableAction = {
-      permission: 'create',
-      icon: Icons.add,
-      name: this.actionLabels.CREATE + ' Zone',
-      click: () => this.openModal('zone')
-    };
-    const migrateMultsiteAction: CdTableAction = {
-      permission: 'read',
-      icon: Icons.exchange,
-      name: this.actionLabels.MIGRATE,
-      click: () => this.openMigrateModal()
-    };
-    const importMultsiteAction: CdTableAction = {
-      permission: 'read',
-      icon: Icons.download,
-      name: this.actionLabels.IMPORT,
-      click: () => this.openImportModal(),
-      disable: () => this.getDisableImport()
-    };
-    const exportMultsiteAction: CdTableAction = {
-      permission: 'read',
-      icon: Icons.upload,
-      name: this.actionLabels.EXPORT,
-      click: () => this.openExportModal(),
-      disable: () => this.getDisableExport()
-    };
-    this.createTableActions = [createRealmAction, createZonegroupAction, createZoneAction];
-    this.migrateTableAction = [migrateMultsiteAction];
-    this.importAction = [importMultsiteAction];
-    this.exportAction = [exportMultsiteAction];
+    this.createTableActions = [
+      {
+        permission: 'create',
+        icon: Icons.add,
+        name: this.actionLabels.CREATE + ' Realm',
+        click: () => this.openModal('realm')
+      },
+      {
+        permission: 'create',
+        icon: Icons.add,
+        name: this.actionLabels.CREATE + ' Zone Group',
+        click: () => this.openModal('zonegroup'),
+        disable: () => this.getDisable()
+      },
+      {
+        permission: 'create',
+        icon: Icons.add,
+        name: this.actionLabels.CREATE + ' Zone',
+        click: () => this.openModal('zone')
+      }
+    ];
+    this.migrateTableAction = [
+      {
+        permission: 'create',
+        icon: Icons.wrench,
+        name: this.actionLabels.MIGRATE,
+        click: () => this.openMigrateModal()
+      }
+    ];
+    this.importAction = [
+      {
+        permission: 'create',
+        icon: Icons.download,
+        name: this.actionLabels.IMPORT,
+        click: () => this.openImportModal(),
+        disable: () => this.getDisableImport()
+      }
+    ];
+    this.exportAction = [
+      {
+        permission: 'create',
+        icon: Icons.upload,
+        name: this.actionLabels.EXPORT,
+        click: () => this.openExportModal(),
+        disable: () => this.getDisableExport()
+      }
+    ];
+    this.multisiteReplicationActions = [
+      {
+        permission: 'create',
+        icon: Icons.wrench,
+        name: this.actionLabels.SETUP_MULTISITE_REPLICATION,
+        click: () =>
+          this.router.navigate([BASE_URL, { outlets: { modal: 'setup-multisite-replication' } }])
+      }
+    ];
 
     const observables = [
       this.rgwRealmService.getAllRealmsInfo(),
@@ -390,7 +414,7 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
     }
     this.realmIds = [];
     this.zoneIds = [];
-    this.getDisableMigrate();
+    this.evaluateMigrateAndReplicationActions();
     this.rgwDaemonService.list().subscribe((data: any) => {
       const realmName = data.map((item: { [x: string]: any }) => item['realm_name']);
       if (
@@ -457,7 +481,7 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
     }
   }
 
-  getDisableMigrate() {
+  evaluateMigrateAndReplicationActions() {
     if (
       this.realms.length === 0 &&
       this.zonegroups.length === 1 &&
@@ -465,11 +489,11 @@ export class RgwMultisiteDetailsComponent implements OnDestroy, OnInit {
       this.zones.length === 1 &&
       this.zones[0].name === 'default'
     ) {
-      this.showMigrateAction = true;
+      this.showMigrateAndReplicationActions = true;
     } else {
-      this.showMigrateAction = false;
+      this.showMigrateAndReplicationActions = false;
     }
-    return this.showMigrateAction;
+    return this.showMigrateAndReplicationActions;
   }
 
   isDeleteDisabled(node: TreeNode): boolean {
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
new file mode 100644 (file)
index 0000000..5c00a54
--- /dev/null
@@ -0,0 +1,253 @@
+<div class="custom-modal-content">
+  <cd-modal [modalRef]="activeModal"
+            [pageURL]="pageURL">
+    <ng-container i18n="form title"
+                  class="modal-title">Set up Multi-site Replication</ng-container>
+    <ng-container class="modal-content">
+      <div class="card">
+        <div class="container-fluid">
+          <cd-wizard [stepsTitle]="stepTitles"></cd-wizard>
+          <div class="card-body vertical-line">
+            <form [formGroup]="multisiteSetupForm"
+                  #formDir="ngForm"
+                  novalidate>
+              <ng-container [ngSwitch]="currentStep?.stepIndex">
+                <cd-alert-panel *ngIf="loading"
+                                spacingClass="mb-3"
+                                type="info">
+                  <span i18n>Please note that this process can take some time. During this period, do not click the back button or close the wizard. Thank you for your patience.</span>
+                </cd-alert-panel>
+                <div *ngSwitchCase="'1'"
+                     class="ms-5">
+                  <h4 class="title"
+                      i18n>Create Realm & Zonegroup</h4>
+                  <br>
+                  <div class="modal-body">
+                    <div class="form-group row">
+                      <label class="cd-col-form-label required"
+                             for="realmName"
+                             i18n>Realm Name</label>
+                      <div class="cd-col-form-input">
+                        <input class="form-control"
+                               type="text"
+                               placeholder="Realm name..."
+                               id="realmName"
+                               name="realmName"
+                               formControlName="realmName">
+                        <cd-help-text>
+                          <span i18n>Enter a unique name for the Realm. The Realm is a logical grouping of all your Zonegroups.</span>
+                        </cd-help-text>
+                        <span class="invalid-feedback"
+                              *ngIf="multisiteSetupForm.showError('realmName', formDir, 'required')"
+                              i18n>This field is required.</span>
+                        <span class="invalid-feedback"
+                              *ngIf="multisiteSetupForm.showError('realmName', formDir, 'uniqueName')"
+                              i18n>The chosen realm name is already in use.</span>
+                      </div>
+                    </div>
+                    <div class="form-group row">
+                      <label class="cd-col-form-label required"
+                             for="zonegroupName"
+                             i18n>Zone Group Name</label>
+                      <div class="cd-col-form-input">
+                        <input class="form-control"
+                               type="text"
+                               placeholder="Zone group name..."
+                               id="zonegroupName"
+                               name="zonegroupName"
+                               formControlName="zonegroupName">
+                        <cd-help-text>
+                          <span i18n>Enter a name for the Zonegroup. Zonegroup will help you identify and manage the group of zones.</span>
+                        </cd-help-text>
+                        <span class="invalid-feedback"
+                              *ngIf="multisiteSetupForm.showError('zonegroupName', formDir, 'required')"
+                              i18n>This field is required.</span>
+                        <span class="invalid-feedback"
+                              *ngIf="multisiteSetupForm.showError('zonegroupName', formDir, 'uniqueName')"
+                              i18n>The chosen zone group name is already in use.</span>
+                      </div>
+                    </div>
+                    <div class="form-group row">
+                      <label class="cd-col-form-label required"
+                             for="zonegroup_endpoints"
+                             i18n>Zonegroup Endpoints</label>
+                      <div class="cd-col-form-input">
+                        <cd-select-badges id="zonegroup_endpoints"
+                                          [data]="rgwEndpoints.value"
+                                          [options]="rgwEndpoints.options"
+                                          [customBadges]="true">
+                        </cd-select-badges>
+                        <cd-help-text>
+                          <span i18n>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</span>
+                        </cd-help-text>
+                      </div>
+                    </div>
+                  </div>
+                </div>
+                <div *ngSwitchCase="'2'"
+                     class="ms-5">
+                  <h4 class="title"
+                      i18n>Create Zone</h4>
+                  <div class="form-group row">
+                    <label class="cd-col-form-label required"
+                           for="zonegroupName"
+                           i18n>Zone Name</label>
+                    <div class="cd-col-form-input">
+                      <input class="form-control"
+                             type="text"
+                             placeholder="Zone name..."
+                             id="zoneName"
+                             name="zoneName"
+                             formControlName="zoneName">
+                      <cd-help-text>
+                        <span i18n>Enter a unique name for the Zone. A Zone represents a distinct data center or geographical location within a Zonegroup.</span>
+                      </cd-help-text>
+                      <span class="invalid-feedback"
+                            *ngIf="multisiteSetupForm.showError('zoneName', formDir, 'required')"
+                            i18n>This field is required.</span>
+                      <span class="invalid-feedback"
+                            *ngIf="multisiteSetupForm.showError('zoneName', formDir, 'uniqueName')"
+                            i18n>The chosen zone name is already in use.</span>
+                    </div>
+                  </div>
+                  <div class="form-group row">
+                    <label class="cd-col-form-label required"
+                           for="zone_endpoints"
+                           i18n>Zone Endpoints</label>
+                    <div class="cd-col-form-input">
+                      <cd-select-badges id="zone_endpoints"
+                                        [data]="rgwEndpoints.value"
+                                        [options]="rgwEndpoints.options"
+                                        [customBadges]="true">
+                      </cd-select-badges>
+                      <cd-help-text>
+                        <span i18n>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</span>
+                      </cd-help-text>
+                    </div>
+                  </div>
+                  <div class="form-group row">
+                    <label class="cd-col-form-label required"
+                           for="username"
+                           i18n>Username</label>
+                    <div class="cd-col-form-input">
+                      <input class="form-control"
+                             type="text"
+                             placeholder="Username..."
+                             id="username"
+                             name="username"
+                             formControlName="username"
+                             ngbTooltip="White spaces at the beginning and end will be trimmed"
+                             i18n-ngbTooltip
+                             cdTrim>
+                      <cd-help-text>
+                        <span i18n>Specify the username for the system user.</span>
+                      </cd-help-text>
+                      <cd-alert-panel type="info"
+                                      [showTitle]="false">
+                        <span i18n>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.</span>
+                      </cd-alert-panel>
+                      <span class="invalid-feedback"
+                            *ngIf="multisiteSetupForm.showError('username', formDir, 'required')"
+                            i18n>This field is required.</span>
+                      <span class="invalid-feedback"
+                            *ngIf="multisiteSetupForm.showError('username', formDir, 'notUnique')"
+                            i18n>The username already exists.</span>
+                    </div>
+                  </div>
+                </div>
+                <div class="ms-5"
+                     *ngSwitchCase="'3'">
+                  <div *ngIf="isMultiClusterConfigured; else exportTokenTemplate">
+                    <h4 class="title"
+                        i18n>Select Cluster</h4>
+                    <div class="form-group row">
+                      <label class="cd-col-form-label required"
+                             for="cluster"
+                             i18n>Cluster</label>
+                      <div class="cd-col-form-input">
+                        <select class="form-select"
+                                id="cluster"
+                                [(ngModel)]="selectedCluster"
+                                formControlName="cluster"
+                                name="cluster">
+                          <option *ngFor="let cluster_detail of clusterDetailsArray"
+                                  [value]="cluster_detail.name">
+                            {{ cluster_detail.cluster_alias }} - {{ cluster_detail.name }}
+                          </option>
+                        </select>
+                        <cd-help-text>
+                          <span i18n>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.</span>
+                        </cd-help-text>
+                        <cd-alert-panel type="info"
+                                        [showTitle]="false">
+                          <span i18n>Before submitting this form, please verify that the selected cluster has an active RGW (Rados Gateway) service running.</span>
+                        </cd-alert-panel>
+                      </div>
+                    </div>
+                  </div>
+                  <ng-template #exportTokenTemplate>
+                    <h4 class="title"
+                        i18n>Export Token</h4>
+                    <div *ngFor="let realminfo of realms">
+                      <div class="form-group row">
+                        <label class="cd-col-form-label"
+                               for="realmName"
+                               i18n>Realm Name</label>
+                        <div class="cd-col-form-input">
+                          <input id="realmName"
+                                 name="realmName"
+                                 type="text"
+                                 [value]="realminfo.realm"
+                                 readonly>
+                          <cd-help-text>
+                            <span i18n>Name of the realm that will be involved in replication.</span>
+                          </cd-help-text>
+                        </div>
+                      </div>
+                      <div class="form-group row">
+                        <label class="cd-col-form-label"
+                               for="token"
+                               i18n>Token</label>
+                        <div class="cd-col-form-input">
+                          <input id="realmToken"
+                                 name="realmToken"
+                                 type="text"
+                                 [value]="realminfo.token"
+                                 class="me-2 mb-4"
+                                 readonly>
+                          <cd-copy-2-clipboard-button [source]="realminfo.token"
+                                                      [byId]="false">
+                          </cd-copy-2-clipboard-button>
+                          <cd-help-text>
+                            <span i18n>This field displays the token needed to import the multisite configuration into a secondary cluster. Copy this token securely and use it on the secondary cluster to replicate the current multisite setup. Ensure that the token is handled securely to prevent unauthorized access.</span>
+                          </cd-help-text>
+                        </div>
+                      </div>
+                      <hr *ngIf="realms.length > 1">
+                    </div>
+                  </ng-template>
+                </div>
+              </ng-container>
+            </form>
+          </div>
+        </div>
+        <div class="card-footer">
+          <button class="btn btn-accent m-2 float-end"
+                  (click)="onNextStep()"
+                  aria-label="Next"
+                  i18n>{{ showSubmitButtonLabel() }}
+            <span *ngIf="loading">
+              <i [ngClass]="[icons.spinner, icons.spin]"></i>
+            </span>
+          </button>
+          <cd-back-button class="m-2 float-end"
+                          aria-label="Close"
+                          (backAction)="onPreviousStep()"
+                          [name]="showCancelButtonLabel()"
+                          [disabled]="loading">
+          </cd-back-button>
+        </div>
+      </div>
+    </ng-container>
+  </cd-modal>
+</div>
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
new file mode 100644 (file)
index 0000000..6f91a28
--- /dev/null
@@ -0,0 +1,11 @@
+.container-fluid {
+  align-items: flex-start;
+  display: flex;
+  padding-left: 0;
+  width: 100%;
+}
+
+::ng-deep .custom-modal-content .modal-content {
+  right: 40vh;
+  width: 140vh;
+}
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
new file mode 100644 (file)
index 0000000..11799a4
--- /dev/null
@@ -0,0 +1,29 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { RgwMultisiteWizardComponent } from './rgw-multisite-wizard.component';
+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 { ToastrModule } from 'ngx-toastr';
+
+describe('RgwMultisiteWizardComponent', () => {
+  let component: RgwMultisiteWizardComponent;
+  let fixture: ComponentFixture<RgwMultisiteWizardComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [RgwMultisiteWizardComponent],
+      imports: [HttpClientTestingModule, SharedModule, ReactiveFormsModule, ToastrModule.forRoot()],
+      providers: [NgbActiveModal]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(RgwMultisiteWizardComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
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
new file mode 100644 (file)
index 0000000..f84419d
--- /dev/null
@@ -0,0 +1,257 @@
+import { Component, OnInit } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { Subscription, forkJoin } from 'rxjs';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+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 { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { Router } from '@angular/router';
+import { map, switchMap } from 'rxjs/operators';
+
+@Component({
+  selector: 'cd-rgw-multisite-wizard',
+  templateUrl: './rgw-multisite-wizard.component.html',
+  styleUrls: ['./rgw-multisite-wizard.component.scss']
+})
+export class RgwMultisiteWizardComponent implements OnInit {
+  multisiteSetupForm: CdFormGroup;
+  currentStep: WizardStepModel;
+  currentStepSub: Subscription;
+  permissions: Permissions;
+  stepTitles = ['Create Realm & Zonegroup', 'Create Zone', 'Select Cluster'];
+  stepsToSkip: { [steps: string]: boolean } = {};
+  daemons: RgwDaemon[] = [];
+  selectedCluster = '';
+  clusterDetailsArray: any;
+  isMultiClusterConfigured = false;
+  exportTokenForm: CdFormGroup;
+  realms: any;
+  loading = false;
+  pageURL: string;
+  icons = Icons;
+  rgwEndpoints: { value: any[]; options: any[]; messages: any };
+
+  constructor(
+    private wizardStepsService: WizardStepsService,
+    public activeModal: NgbActiveModal,
+    public actionLabels: ActionLabelsI18n,
+    private rgwDaemonService: RgwDaemonService,
+    private multiClusterService: MultiClusterService,
+    private rgwMultisiteService: RgwMultisiteService,
+    public notificationService: NotificationService,
+    private router: Router
+  ) {
+    this.pageURL = 'rgw/multisite';
+    this.currentStepSub = this.wizardStepsService
+      .getCurrentStep()
+      .subscribe((step: WizardStepModel) => {
+        this.currentStep = step;
+      });
+    this.currentStep.stepIndex = 1;
+    this.createForm();
+    this.rgwEndpoints = {
+      value: [],
+      options: [],
+      messages: new SelectMessages({
+        empty: $localize`There are no endpoints.`,
+        filter: $localize`Select endpoints`
+      })
+    };
+  }
+
+  ngOnInit(): void {
+    this.rgwDaemonService
+      .list()
+      .pipe(
+        switchMap((daemons) => {
+          this.daemons = daemons;
+          const daemonStatsObservables = daemons.map((daemon) =>
+            this.rgwDaemonService.get(daemon.id).pipe(
+              map((daemonStats) => ({
+                hostname: daemon.server_hostname,
+                port: daemon.port,
+                frontendConfig: daemonStats['rgw_metadata']['frontend_config#0']
+              }))
+            )
+          );
+          return forkJoin(daemonStatsObservables);
+        })
+      )
+      .subscribe((daemonStatsArray) => {
+        this.rgwEndpoints.value = daemonStatsArray.map((daemonStats) => {
+          const protocol = daemonStats.frontendConfig.includes('ssl_port') ? 'https' : 'http';
+          return `${protocol}://${daemonStats.hostname}:${daemonStats.port}`;
+        });
+        const options: SelectOption[] = this.rgwEndpoints.value.map(
+          (endpoint: string) => new SelectOption(false, endpoint, '')
+        );
+        this.rgwEndpoints.options = [...options];
+      });
+
+    this.multiClusterService.getCluster().subscribe((clusters) => {
+      this.clusterDetailsArray = Object.values(clusters['config'])
+        .flat()
+        .filter((cluster) => cluster['cluster_alias'] !== 'local-cluster');
+      this.isMultiClusterConfigured = this.clusterDetailsArray.length > 0;
+      if (!this.isMultiClusterConfigured) {
+        this.stepTitles = ['Create Realm & Zonegroup', 'Create Zone', 'Export Multi-site token'];
+      } else {
+        this.selectedCluster = this.clusterDetailsArray[0]['name'];
+      }
+    });
+  }
+
+  createForm() {
+    this.multisiteSetupForm = new CdFormGroup({
+      realmName: new UntypedFormControl('default_realm', {
+        validators: [Validators.required]
+      }),
+      zonegroupName: new UntypedFormControl('default_zonegroup', {
+        validators: [Validators.required]
+      }),
+      zonegroup_endpoints: new UntypedFormControl(null, [Validators.required]),
+      zoneName: new UntypedFormControl('default_zone', {
+        validators: [Validators.required]
+      }),
+      zone_endpoints: new UntypedFormControl(null, {
+        validators: [Validators.required]
+      }),
+      username: new UntypedFormControl('default_system_user', {
+        validators: [Validators.required]
+      }),
+      cluster: new UntypedFormControl(null, {
+        validators: [Validators.required]
+      })
+    });
+
+    if (!this.isMultiClusterConfigured) {
+      this.exportTokenForm = new CdFormGroup({});
+    }
+  }
+
+  showSubmitButtonLabel() {
+    if (this.isMultiClusterConfigured) {
+      return !this.wizardStepsService.isLastStep()
+        ? this.actionLabels.NEXT
+        : $localize`Configure Multi-site`;
+    } else {
+      return !this.wizardStepsService.isLastStep() ? this.actionLabels.NEXT : $localize`Close`;
+    }
+  }
+
+  showCancelButtonLabel() {
+    return !this.wizardStepsService.isFirstStep()
+      ? this.actionLabels.BACK
+      : this.actionLabels.CANCEL;
+  }
+
+  onNextStep() {
+    if (!this.wizardStepsService.isLastStep()) {
+      this.wizardStepsService.getCurrentStep().subscribe((step: WizardStepModel) => {
+        this.currentStep = step;
+      });
+      if (this.currentStep.stepIndex === 2 && !this.isMultiClusterConfigured) {
+        this.onSubmit();
+      } else {
+        this.wizardStepsService.moveToNextStep();
+      }
+    } else {
+      this.onSubmit();
+    }
+  }
+
+  onSubmit() {
+    this.loading = true;
+    const values = this.multisiteSetupForm.value;
+    const realmName = values['realmName'];
+    const zonegroupName = values['zonegroupName'];
+    const zonegroupEndpoints = this.rgwEndpoints.value.join(',');
+    const zoneName = values['zoneName'];
+    const zoneEndpoints = this.rgwEndpoints.value.join(',');
+    const username = values['username'];
+    if (!this.isMultiClusterConfigured) {
+      if (this.wizardStepsService.isLastStep()) {
+        this.activeModal.close();
+        this.refreshMultisitePage();
+      } else {
+        this.rgwMultisiteService
+          .setUpMultisiteReplication(
+            realmName,
+            zonegroupName,
+            zonegroupEndpoints,
+            zoneName,
+            zoneEndpoints,
+            username
+          )
+          .subscribe((data: object[]) => {
+            this.loading = false;
+            this.realms = data;
+            this.wizardStepsService.moveToNextStep();
+            this.showSuccessNotification();
+          });
+      }
+    } else {
+      const cluster = values['cluster'];
+      this.rgwMultisiteService
+        .setUpMultisiteReplication(
+          realmName,
+          zonegroupName,
+          zonegroupEndpoints,
+          zoneName,
+          zoneEndpoints,
+          username,
+          cluster
+        )
+        .subscribe(
+          () => {
+            this.showSuccessNotification();
+            this.activeModal.close();
+            this.refreshMultisitePage();
+          },
+          () => {
+            this.multisiteSetupForm.setErrors({ cdSubmitButton: true });
+          }
+        );
+    }
+  }
+
+  showSuccessNotification() {
+    this.notificationService.show(
+      NotificationType.success,
+      $localize`Multi-site setup completed successfully.`
+    );
+  }
+
+  refreshMultisitePage() {
+    const currentRoute = this.router.url.split('?')[0];
+    const navigateTo = currentRoute.includes('multisite') ? '/pool' : '/';
+    this.router.navigateByUrl(navigateTo, { skipLocationChange: true }).then(() => {
+      this.router.navigate([currentRoute]);
+    });
+  }
+
+  onPreviousStep() {
+    if (!this.wizardStepsService.isFirstStep()) {
+      this.wizardStepsService.moveToPreviousStep();
+    } else {
+      this.activeModal.close();
+    }
+  }
+
+  onSkip() {
+    const stepTitle = this.stepTitles[this.currentStep.stepIndex - 1];
+    this.stepsToSkip[stepTitle] = true;
+    this.onNextStep();
+  }
+}
index dde6cff4866be202da95ec7ccf7a4b4586031d16..9767230ba1b402bdd2a4be936e1aad75d83f948c 100644 (file)
@@ -58,6 +58,7 @@ import { RgwMultisiteSyncPolicyComponent } from './rgw-multisite-sync-policy/rgw
 import { RgwMultisiteSyncPolicyFormComponent } from './rgw-multisite-sync-policy-form/rgw-multisite-sync-policy-form.component';
 import { RgwConfigurationPageComponent } from './rgw-configuration-page/rgw-configuration-page.component';
 import { RgwConfigDetailsComponent } from './rgw-config-details/rgw-config-details.component';
+import { RgwMultisiteWizardComponent } from './rgw-multisite-wizard/rgw-multisite-wizard.component';
 
 @NgModule({
   imports: [
@@ -120,7 +121,8 @@ import { RgwConfigDetailsComponent } from './rgw-config-details/rgw-config-detai
     RgwMultisiteSyncPolicyComponent,
     RgwMultisiteSyncPolicyFormComponent,
     RgwConfigDetailsComponent,
-    RgwConfigurationPageComponent
+    RgwConfigurationPageComponent,
+    RgwMultisiteWizardComponent
   ],
   providers: [TitleCasePipe]
 })
@@ -211,6 +213,7 @@ const routes: Routes = [
   },
   {
     path: 'multisite',
+    component: RgwMultisiteDetailsComponent,
     data: { breadcrumbs: 'Multi-site' },
     children: [
       { path: '', component: RgwMultisiteDetailsComponent },
@@ -228,6 +231,11 @@ const routes: Routes = [
         path: `sync-policy/${URLVerbs.EDIT}/:groupName/:bucketName`,
         component: RgwMultisiteSyncPolicyFormComponent,
         data: { breadcrumbs: `${ActionLabels.EDIT} Sync Policy` }
+      },
+      {
+        path: 'setup-multisite-replication',
+        component: RgwMultisiteWizardComponent,
+        outlet: 'modal'
       }
     ]
   },
index cc03042815e7bfa6c5d06636177cb2bed5fb325e..e77e6afab1f926350eeb2ac7b16c39573ee15301 100644 (file)
@@ -71,4 +71,28 @@ export class RgwMultisiteService {
     }
     return this.http.delete(`${this.url}/sync-policy-group/${group_id}`, { params });
   }
+
+  setUpMultisiteReplication(
+    realmName: string,
+    zonegroupName: string,
+    zonegroupEndpoints: string,
+    zoneName: string,
+    zoneEndpoints: string,
+    username: string,
+    cluster?: string
+  ) {
+    let params = new HttpParams()
+      .set('realm_name', realmName)
+      .set('zonegroup_name', zonegroupName)
+      .set('zonegroup_endpoints', zonegroupEndpoints)
+      .set('zone_name', zoneName)
+      .set('zone_endpoints', zoneEndpoints)
+      .set('username', username);
+
+    if (cluster) {
+      params = params.set('cluster_fsid', cluster);
+    }
+
+    return this.http.post(`${this.uiUrl}/multisite-replications`, null, { params: params });
+  }
 }
index 2d8a787c0d1467dda572d6c1357d6ecccd3f8c1e..077232bd9a972392b341f5d7c1c9fa46019b0c97 100644 (file)
@@ -1,6 +1,7 @@
 <button class="btn btn-light tc_backButton"
         aria-label="Back"
         (click)="back()"
+        [disabled]="disabled"
         type="button">
   {{ name }}
 </button>
index 64563ea2c3bbe76a6b2a8012b56605583a8bcc42..c84c19a818876d718acc1f7f4d329f21244bbf50 100644 (file)
@@ -11,6 +11,7 @@ import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 export class BackButtonComponent implements OnInit {
   @Output() backAction = new EventEmitter();
   @Input() name?: string;
+  @Input() disabled = false;
 
   constructor(private location: Location, private actionLabels: ActionLabelsI18n) {}
 
@@ -19,10 +20,12 @@ export class BackButtonComponent implements OnInit {
   }
 
   back() {
-    if (this.backAction.observers.length === 0) {
-      this.location.back();
-    } else {
-      this.backAction.emit();
+    if (!this.disabled) {
+      if (this.backAction.observers.length === 0) {
+        this.location.back();
+      } else {
+        this.backAction.emit();
+      }
     }
   }
 }
index 25aa3e1df855e63b581952bada82332790582973..7ffd1b320592febaaf9742e8a198a6ca9780ca14 100644 (file)
@@ -3,7 +3,7 @@
     <nav class="col">
       <ul class="nav nav-pills flex-column"
           *ngFor="let step of steps | async; let i = index;">
-        <li class="nav-item">
+        <li class="nav-item text-nowrap">
           <a class="nav-link"
              (click)="onStepClick(step)"
              [ngClass]="{active: currentStep.stepIndex === step.stepIndex}">
index 24915507fd9aea2e6bbf291e567adf87a4cfef7b..3140f481f45768b08abf817c579788a26484f3e4 100644 (file)
@@ -149,6 +149,7 @@ export class ActionLabelsI18n {
   RECONNECT: string;
   AUTHORIZE: string;
   EXPAND_CLUSTER: string;
+  SETUP_MULTISITE_REPLICATION: string;
 
   constructor() {
     /* Create a new item */
@@ -158,6 +159,8 @@ export class ActionLabelsI18n {
 
     this.IMPORT = $localize`Import`;
 
+    this.SETUP_MULTISITE_REPLICATION = $localize`Setup Multi-site Replication`;
+
     this.MIGRATE = $localize`Migrate`;
 
     /* Destroy an existing item */