@EndpointDoc("Create or update the sync flow")
@CreatePermission
def create_sync_flow(self, flow_id: str, flow_type: str, group_id: str,
- source_zone='', destination_zone='', zones: Optional[List[str]] = None,
+ source_zone: Optional[List[str]] = None,
+ destination_zone: Optional[List[str]] = None,
+ zones: Optional[List[str]] = None,
bucket_name=''):
multisite_instance = RgwMultisite()
return multisite_instance.create_sync_flow(group_id, flow_id, flow_type, zones,
// Waits for item to be removed from table
getRow(name).should('not.exist');
}
+
+ getNestedTableCell(
+ tableSelector: string,
+ columnIndex: number,
+ exactContent: string,
+ partialMatch = false
+ ) {
+ this.waitDataTableToLoad();
+ this.clearTableSearchInput();
+ this.searchNestedTable(tableSelector, exactContent);
+ if (partialMatch) {
+ return cy
+ .get(`${tableSelector} datatable-body-row datatable-body-cell:nth-child(${columnIndex})`)
+ .should('contain', exactContent);
+ }
+ return cy
+ .get(`${tableSelector}`)
+ .contains(
+ `datatable-body-row datatable-body-cell:nth-child(${columnIndex})`,
+ new RegExp(`^${exactContent}$`)
+ );
+ }
+
+ searchNestedTable(tableSelector: string, text: string) {
+ this.waitDataTableToLoad();
+
+ this.setPageSize('10');
+ cy.get(`${tableSelector} [aria-label=search]`).first().clear({ force: true }).type(text);
+ }
}
it('should show sync policy tab as a second tab', () => {
multisite.getTabText(1).should('eq', 'Sync Policy');
});
-
- it('should show empty table in Sync Policy page', () => {
- multisite.getTab('Sync Policy').click();
- multisite.getDataTables().should('exist');
- });
});
describe('create, edit & delete sync group policy', () => {
multisite.delete('test');
});
});
+
+ describe('create, edit & delete symmetrical sync Flow', () => {
+ it('Preparing...(creating sync group policy)', () => {
+ multisite.navigateTo('create');
+ multisite.create('test', 'Enabled');
+ multisite.getFirstTableCell('test').should('exist');
+ });
+ describe('symmetrical Flow creation started', () => {
+ beforeEach(() => {
+ multisite.getTab('Sync Policy').click();
+ multisite.getExpandCollapseElement().click();
+ });
+
+ it('should create flow', () => {
+ multisite.createSymmetricalFlow('new-sym-flow', ['zone1-zg1-realm1']);
+ });
+
+ it('should modify flow zones', () => {
+ multisite.editSymFlow('new-sym-flow', 'zone2-zg1-realm1');
+ });
+
+ it('should delete flow', () => {
+ multisite.deleteSymFlow('new-sym-flow');
+ });
+ });
+ });
+
+ describe('create, edit & delete directional sync Flow', () => {
+ beforeEach(() => {
+ multisite.getTab('Sync Policy').click();
+ multisite.getExpandCollapseElement().click();
+ });
+
+ it('should create flow', () => {
+ multisite.createDirectionalFlow(
+ 'new-dir-flow',
+ ['zone1-zg1-realm1', 'zone2-zg1-realm1'],
+ ['new-zone']
+ );
+ });
+ });
});
pages = pages;
columnIndex = {
- status: 3
+ status: 4
};
@PageHelper.restrictTo(pages.create.url)
.find('.badge-warning')
.should('contain', status);
}
+
+ @PageHelper.restrictTo(pages.index.url)
+ createSymmetricalFlow(flow_id: string, zones: string[]) {
+ cy.get('cd-rgw-multisite-sync-policy-details').should('exist');
+ this.getTab('Flow').should('exist');
+ this.getTab('Flow').click();
+ cy.request({
+ method: 'GET',
+ url: '/api/rgw/daemon',
+ headers: { Accept: 'application/vnd.ceph.api.v1.0+json' }
+ });
+ cy.get('cd-rgw-multisite-sync-policy-details .table-actions button').first().click();
+ cy.get('cd-rgw-multisite-sync-flow-modal').should('exist');
+
+ // Enter in flow_id
+ cy.get('#flow_id').type(flow_id);
+ // Select zone
+ cy.get('a[data-testid=select-menu-edit]').click();
+ for (const zone of zones) {
+ cy.get('.popover-body div.select-menu-item-content').contains(zone).click();
+ }
+
+ cy.get('button.tc_submitButton').click();
+
+ cy.get('cd-rgw-multisite-sync-policy-details .datatable-body-cell-label').should(
+ 'contain',
+ flow_id
+ );
+
+ cy.get('cd-rgw-multisite-sync-policy-details')
+ .first()
+ .find('[aria-label=search]')
+ .first()
+ .clear({ force: true })
+ .type(flow_id);
+ }
+
+ @PageHelper.restrictTo(pages.index.url)
+ editSymFlow(flow_id: string, zoneToAdd: string) {
+ cy.get('cd-rgw-multisite-sync-policy-details').should('exist');
+ this.getTab('Flow').should('exist');
+ this.getTab('Flow').click();
+ cy.request({
+ method: 'GET',
+ url: '/api/rgw/daemon',
+ headers: { Accept: 'application/vnd.ceph.api.v1.0+json' }
+ });
+
+ cy.get('cd-rgw-multisite-sync-policy-details').within(() => {
+ cy.get('.datatable-body-cell-label').should('contain', flow_id);
+ cy.get('[aria-label=search]').first().clear({ force: true }).type(flow_id);
+ cy.get('input.cd-datatable-checkbox').first().check();
+ cy.get('.table-actions button').first().click();
+ });
+ cy.get('cd-rgw-multisite-sync-flow-modal').should('exist');
+
+ // Enter in flow_id
+ cy.get('#flow_id').wait(100).should('contain.value', flow_id);
+ // Select zone
+ cy.get('a[data-testid=select-menu-edit]').click();
+
+ cy.get('.popover-body div.select-menu-item-content').contains(zoneToAdd).click();
+
+ cy.get('button.tc_submitButton').click();
+
+ this.getNestedTableCell('cd-rgw-multisite-sync-policy-details', 3, zoneToAdd, true);
+ }
+
+ getTableCellWithContent(nestedClass: string, content: string) {
+ return cy.contains(`${nestedClass} .datatable-body-cell-label`, content);
+ }
+
+ @PageHelper.restrictTo(pages.index.url)
+ deleteSymFlow(flow_id: string) {
+ cy.get('cd-rgw-multisite-sync-policy-details').should('exist');
+ this.getTab('Flow').should('exist');
+ this.getTab('Flow').click();
+ cy.get('cd-rgw-multisite-sync-policy-details').within(() => {
+ cy.get('.datatable-body-cell-label').should('contain', flow_id);
+ cy.get('[aria-label=search]').first().clear({ force: true }).type(flow_id);
+ });
+
+ const getRow = this.getTableCellWithContent.bind(this);
+ getRow('cd-rgw-multisite-sync-policy-details', flow_id).click();
+
+ cy.get('cd-rgw-multisite-sync-policy-details').within(() => {
+ cy.get('.table-actions button.dropdown-toggle').first().click(); // open submenu
+ cy.get(`button.delete`).first().click();
+ });
+
+ cy.get('cd-modal .custom-control-label').click();
+ cy.get('[aria-label="Delete Flow"]').click();
+ cy.get('cd-modal').should('not.exist');
+
+ cy.get('cd-rgw-multisite-sync-policy-details')
+ .first()
+ .within(() => {
+ cy.get('[aria-label=search]').first().clear({ force: true }).type(flow_id);
+ });
+ // Waits for item to be removed from table
+ getRow(flow_id).should('not.exist');
+ }
+
+ createDirectionalFlow(flow_id: string, source_zones: string[], dest_zones: string[]) {
+ cy.get('cd-rgw-multisite-sync-policy-details').should('exist');
+ this.getTab('Flow').should('exist');
+ this.getTab('Flow').click();
+ cy.request({
+ method: 'GET',
+ url: '/api/rgw/daemon',
+ headers: { Accept: 'application/vnd.ceph.api.v1.0+json' }
+ });
+ cy.get('cd-rgw-multisite-sync-policy-details cd-table')
+ .eq(1)
+ .find('.table-actions button')
+ .first()
+ .click();
+ cy.get('cd-rgw-multisite-sync-flow-modal').should('exist');
+ cy.wait(WAIT_TIMER);
+ // Enter in flow_id
+ cy.get('#flow_id').type(flow_id);
+ // Select source zone
+ cy.get('a[data-testid=select-menu-edit]').first().click();
+ for (const zone of source_zones) {
+ cy.get('.popover-body div.select-menu-item-content').contains(zone).click();
+ }
+ cy.get('cd-rgw-multisite-sync-flow-modal').click();
+
+ // Select destination zone
+ cy.get('a[data-testid=select-menu-edit]').eq(1).click();
+ for (const dest_zone of dest_zones) {
+ cy.get('.popover-body').find('input[type="text"]').type(`${dest_zone}{enter}`);
+ }
+ cy.get('button.tc_submitButton').click();
+
+ cy.get('cd-rgw-multisite-sync-policy-details cd-table')
+ .eq(1)
+ .find('[aria-label=search]')
+ .first()
+ .clear({ force: true })
+ .type(dest_zones[0]);
+ cy.get('cd-rgw-multisite-sync-policy-details cd-table')
+ .eq(1)
+ .find('.datatable-body-cell-label')
+ .should('contain', dest_zones[0]);
+ }
}
--- /dev/null
+import { Validators } from '@angular/forms';
+import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+
+interface Zone {
+ selected: string[];
+ available: SelectOption[];
+ validators: any[];
+ messages: SelectMessages;
+}
+
+export class ZoneData {
+ data: Zone;
+ customBadges: boolean;
+
+ constructor(customBadges: boolean = false, filterMsg: string) {
+ this.customBadges = customBadges;
+ this.data = {
+ selected: [],
+ available: [],
+ validators: [Validators.pattern('[A-Za-z0-9_-]+|\\*'), Validators.maxLength(50)],
+ messages: new SelectMessages({
+ empty: $localize`No zones added`,
+ customValidations: {
+ pattern: $localize`Allowed characters '-_a-zA-Z0-9|*'`,
+ maxlength: $localize`Maximum length is 50 characters`
+ },
+ filter: $localize`${filterMsg}`,
+ add: $localize`Add zone`
+ })
+ };
+ }
+}
FORBIDDEN = 'forbidden',
ALLOWED = 'allowed'
}
+
+export enum FlowType {
+ directional = 'directional',
+ symmetrical = 'symmetrical'
+}
--- /dev/null
+<cd-modal [modalRef]="activeModal">
+ <ng-container
+ i18n="form title"
+ class="modal-title">{{ action | titlecase }} {{ groupType | upperFirst }} Flow</ng-container>
+
+ <ng-container class="modal-content">
+ <form
+ name="flowForm"
+ #frm="ngForm"
+ [formGroup]="currentFormGroupContext"
+ novalidate>
+ <div class="modal-body">
+ <div class="form-group row">
+ <label
+ class="cd-col-form-label required"
+ for="flow_id"
+ i18n>Name</label>
+ <div class="cd-col-form-input">
+ <input
+ class="form-control"
+ type="text"
+ placeholder="Flow Name..."
+ id="flow_id"
+ name="flow_id"
+ formControlName="flow_id"
+ [readonly]="editing"/>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="bucket"
+ i18n>Bucket Name</label>
+ <div class="cd-col-form-input">
+ <input
+ id="bucket"
+ name="bucket"
+ class="form-control"
+ type="text"
+ i18n-placeholder
+ placeholder="Bucket Name..."
+ formControlName="bucket_name"
+ [readonly]="true"/>
+ <span
+ class="invalid-feedback"
+ *ngIf="currentFormGroupContext.showError('bucket_name', frm, 'bucketNameNotAllowed')"
+ i18n>The bucket with chosen name does not exist.</span>
+ </div>
+ </div>
+ <ng-container *ngIf="groupType == flowType.symmetrical; else directionalFlow">
+ <div class="form-group row">
+ <label
+ class="cd-col-form-label required"
+ for="zones">
+ <ng-container i18n>Zones</ng-container>
+ <cd-helper>
+ <span i18n>Flow need to be associated with atleast one zone</span>
+ </cd-helper>
+ </label>
+ <div class="cd-col-form-input">
+ <ng-container *ngTemplateOutlet="zoneMultiSelect;context: { name: 'zones', zone: zones }"></ng-container>
+ </div>
+ </div>
+ </ng-container>
+ <ng-template #directionalFlow>
+ <div class="form-group row">
+ <label
+ class="cd-col-form-label required"
+ for="source_zone"
+ i18n>Source Zone
+ </label>
+ <div class="cd-col-form-input">
+ <ng-container *ngTemplateOutlet="zoneMultiSelect;context: { name: 'source_zone', zone: sourceZones }"></ng-container>
+ </div>
+ </div>
+ <div class="form-group row">
+ <label
+ class="cd-col-form-label required"
+ for="destination_zone"
+ i18n>Destination Zone</label>
+ <div class="cd-col-form-input">
+ <ng-container *ngTemplateOutlet="zoneMultiSelect;context: { name: 'destination_zone', zone: destinationZones }"></ng-container>
+ </div>
+ </div>
+ </ng-template>
+ </div>
+ <div class="modal-footer">
+ <cd-form-button-panel
+ (submitActionEvent)="submit()"
+ [form]="currentFormGroupContext"
+ [submitText]="(action | titlecase) + ' ' + (groupType | upperFirst) + ' ' + 'Flow'"></cd-form-button-panel>
+ </div>
+ </form>
+ </ng-container>
+ </cd-modal>
+
+<ng-template
+ #zoneMultiSelect
+ let-name="name"
+ let-zone="zone">
+ <cd-select-badges
+ [id]="name"
+ [name]="name"
+ [customBadges]="zone.customBadges"
+ [customBadgeValidators]="zone.data.validators"
+ [messages]="zone.data.messages"
+ [data]="zone.data.selected"
+ [options]="zone.data.available"
+ (selection)="zoneSelection()">
+ </cd-select-badges>
+ <i
+ *ngIf="zone.data.selected.length <= 0"
+ i18n-title
+ title="Flow should be associated with {{name?.split('_')}}"
+ class="{{ icons.warning }} icon-warning-color">
+ </i>
+ <span
+ class="invalid-feedback"
+ *ngIf="currentFormGroupContext.showError(name, frm, 'required')"
+ i18n>{{name?.split('_')}} selection is required!
+ </span>
+</ng-template>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RgwMultisiteSyncFlowModalComponent } from './rgw-multisite-sync-flow-modal.component';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { ToastrModule } from 'ngx-toastr';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { CommonModule } from '@angular/common';
+
+enum FlowType {
+ symmetrical = 'symmetrical',
+ directional = 'directional'
+}
+describe('RgwMultisiteSyncFlowModalComponent', () => {
+ let component: RgwMultisiteSyncFlowModalComponent;
+ let fixture: ComponentFixture<RgwMultisiteSyncFlowModalComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [RgwMultisiteSyncFlowModalComponent],
+ imports: [
+ HttpClientTestingModule,
+ ToastrModule.forRoot(),
+ PipesModule,
+ ReactiveFormsModule,
+ CommonModule
+ ],
+ providers: [NgbActiveModal]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(RgwMultisiteSyncFlowModalComponent);
+ component = fixture.componentInstance;
+ component.groupType = FlowType.symmetrical;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { catchError, switchMap } from 'rxjs/operators';
+import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service';
+import { RgwDaemon } from '../models/rgw-daemon';
+import { FlowType, RgwZonegroup } from '../models/rgw-multisite';
+import { of } from 'rxjs';
+import { SelectOption } from '~/app/shared/components/select/select-option.model';
+import _ from 'lodash';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { ZoneData } from '../models/rgw-multisite-zone-selector';
+
+@Component({
+ selector: 'cd-rgw-multisite-sync-flow-modal',
+ templateUrl: './rgw-multisite-sync-flow-modal.component.html',
+ styleUrls: ['./rgw-multisite-sync-flow-modal.component.scss']
+})
+export class RgwMultisiteSyncFlowModalComponent implements OnInit {
+ action: string;
+ editing: boolean = false;
+ groupType: FlowType;
+ groupExpandedRow: any;
+ flowSelectedRow: any;
+ syncPolicyDirectionalFlowForm: CdFormGroup;
+ syncPolicySymmetricalFlowForm: CdFormGroup;
+ syncPolicyPipeForm: CdFormGroup;
+ currentFormGroupContext: CdFormGroup;
+ flowType = FlowType;
+ icons = Icons;
+ zones = new ZoneData(false, 'Filter Zones');
+ sourceZones = new ZoneData(false, 'Filter Zones');
+ destinationZones = new ZoneData(true, 'Filter or Add Zones');
+
+ constructor(
+ public activeModal: NgbActiveModal,
+ public actionLabels: ActionLabelsI18n,
+ public notificationService: NotificationService,
+ private rgwDaemonService: RgwDaemonService,
+ private rgwZonegroupService: RgwZonegroupService,
+ private rgwMultisiteService: RgwMultisiteService
+ ) {}
+
+ ngOnInit(): void {
+ if (this.action === 'edit') {
+ this.editing = true;
+ }
+ if (this.groupType === FlowType.symmetrical) {
+ this.createSymmetricalFlowForm();
+ this.currentFormGroupContext = _.cloneDeep(this.syncPolicySymmetricalFlowForm);
+ } else if (this.groupType === FlowType.directional) {
+ this.createDirectionalFlowForm();
+ this.currentFormGroupContext = _.cloneDeep(this.syncPolicyDirectionalFlowForm);
+ }
+
+ if (this.editing) {
+ this.currentFormGroupContext.patchValue({
+ flow_id: this.flowSelectedRow.id,
+ bucket_name: this.groupExpandedRow.bucket || ''
+ });
+ }
+
+ this.rgwDaemonService.selectedDaemon$
+ .pipe(
+ switchMap((daemon: RgwDaemon) => {
+ if (daemon) {
+ const zonegroupObj = new RgwZonegroup();
+ zonegroupObj.name = daemon?.zonegroup_name;
+ return this.rgwZonegroupService.get(zonegroupObj).pipe(
+ catchError(() => {
+ return of([]);
+ })
+ );
+ } else {
+ return of([]);
+ }
+ })
+ )
+ .subscribe((zonegroupData: any) => {
+ if (zonegroupData && zonegroupData?.zones?.length > 0) {
+ const zones: any = [];
+ zonegroupData.zones.forEach((zone: any) => {
+ zones.push(new SelectOption(false, zone.name, ''));
+ });
+ this.zones.data.available = [...zones];
+ this.sourceZones.data.available = [...zones];
+ if (this.editing) {
+ if (this.groupType === FlowType.symmetrical) {
+ this.zones.data.selected = this.flowSelectedRow.zones;
+ } else {
+ this.destinationZones.data.selected = [this.flowSelectedRow.dest_zone];
+ this.sourceZones.data.selected = [this.flowSelectedRow.source_zone];
+ }
+ this.zoneSelection();
+ }
+ }
+ });
+ }
+
+ createSymmetricalFlowForm() {
+ this.syncPolicySymmetricalFlowForm = new CdFormGroup({
+ ...this.commonFormControls(FlowType.symmetrical),
+ zones: new UntypedFormControl([], {
+ validators: [Validators.required]
+ })
+ });
+ }
+
+ createDirectionalFlowForm() {
+ this.syncPolicyDirectionalFlowForm = new CdFormGroup({
+ ...this.commonFormControls(FlowType.directional),
+ source_zone: new UntypedFormControl('', {
+ validators: [Validators.required]
+ }),
+ destination_zone: new UntypedFormControl('', {
+ validators: [Validators.required]
+ })
+ });
+ }
+
+ commonFormControls(flowType: FlowType) {
+ return {
+ bucket_name: new UntypedFormControl(this.groupExpandedRow?.bucket),
+ group_id: new UntypedFormControl(this.groupExpandedRow?.groupName, {
+ validators: [Validators.required]
+ }),
+ flow_id: new UntypedFormControl('', {
+ validators: [Validators.required]
+ }),
+ flow_type: new UntypedFormControl(flowType, {
+ validators: [Validators.required]
+ })
+ };
+ }
+
+ zoneSelection() {
+ if (this.groupType === FlowType.symmetrical) {
+ this.currentFormGroupContext.patchValue({
+ zones: this.zones.data.selected
+ });
+ } else {
+ this.currentFormGroupContext.patchValue({
+ source_zone: this.sourceZones.data.selected,
+ destination_zone: this.destinationZones.data.selected
+ });
+ }
+ }
+
+ submit() {
+ if (this.currentFormGroupContext.invalid) {
+ return;
+ }
+ // Ensure that no validation is pending
+ if (this.currentFormGroupContext.pending) {
+ this.currentFormGroupContext.setErrors({ cdSubmitButton: true });
+ return;
+ }
+ this.rgwMultisiteService.createEditSyncFlow(this.currentFormGroupContext.value).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ $localize`Created Sync Flow '${this.currentFormGroupContext.getValue('flow_id')}'`
+ );
+ this.activeModal.close('success');
+ },
+ () => {
+ // Reset the 'Submit' button.
+ this.currentFormGroupContext.setErrors({ cdSubmitButton: true });
+ this.activeModal.dismiss();
+ }
+ );
+ }
+}
--- /dev/null
+<ng-container *ngIf="expandedRow">
+ <nav
+ ngbNav
+ #nav="ngbNav"
+ class="nav-tabs"
+ cdStatefulTab="sync-policy-details">
+ <ng-container ngbNavItem="flow">
+ <a
+ ngbNavLink
+ i18n>Flow</a>
+ <ng-template ngbNavContent>
+ <legend>
+ Symmetrical
+ <cd-help-text>
+ It can define symmetrical data flow, in which multiple zones sync data from each other.
+ </cd-help-text>
+ </legend>
+ <cd-table
+ #table
+ [autoReload]="false"
+ [data]="symmetricalFlowData"
+ [columns]="symmetricalFlowCols"
+ columnMode="flex"
+ selectionType="multiClick"
+ [searchableObjects]="true"
+ [hasDetails]="false"
+ [serverSide]="false"
+ [count]="0"
+ [maxLimit]="25"
+ [toolHeader]="true"
+ (updateSelection)="updateSelection($event, flowType.symmetrical)"
+ (fetchData)="loadFlowData($event)">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions
+ [permission]="permission"
+ [selection]="symFlowSelection"
+ class="btn-group"
+ [tableActions]="symFlowTableActions" >
+ </cd-table-actions>
+ </div>
+ </cd-table>
+ <legend>
+ Directional
+ <cd-help-text>
+ It can define directional data flow, in which the data moves in one way, from one zone to another.
+ </cd-help-text>
+ </legend>
+ <cd-table
+ #table
+ [autoReload]="false"
+ [data]="directionalFlowData"
+ [columns]="directionalFlowCols"
+ columnMode="flex"
+ selectionType="multiClick"
+ [searchableObjects]="true"
+ [hasDetails]="false"
+ [serverSide]="false"
+ [count]="0"
+ [maxLimit]="25"
+ [toolHeader]="true"
+ (updateSelection)="updateSelection($event, flowType.directional)"
+ (fetchData)="loadFlowData($event)">
+ <div class="table-actions btn-toolbar">
+ <cd-table-actions
+ [permission]="permission"
+ [selection]="dirFlowSelection"
+ class="btn-group"
+ [tableActions]="dirFlowTableActions">
+ </cd-table-actions>
+ </div>
+ </cd-table>
+ <cd-alert-panel
+ type="info"
+ *ngIf="dirFlowSelection.hasSelection">
+ 'Edit' and 'Delete' functionalities for Directional flow are disabled for now due to some internal dependency. They will be enabled once the issue is resolved.
+ </cd-alert-panel>
+ </ng-template>
+ </ng-container>
+ </nav>
+
+ <div [ngbNavOutlet]="nav"></div>
+</ng-container>
+
+<ng-template #deleteTpl>
+ <cd-alert-panel type="danger"
+ i18n>
+ Are you sure you want to delete these Flow?
+ </cd-alert-panel>
+</ng-template>
--- /dev/null
+::ng-deep datatable-scroller {
+ width: 100% !important;
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { RgwMultisiteSyncPolicyDetailsComponent } from './rgw-multisite-sync-policy-details.component';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ToastrModule } from 'ngx-toastr';
+import { PipesModule } from '~/app/shared/pipes/pipes.module';
+
+describe('RgwMultisiteSyncPolicyDetailsComponent', () => {
+ let component: RgwMultisiteSyncPolicyDetailsComponent;
+ let fixture: ComponentFixture<RgwMultisiteSyncPolicyDetailsComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [RgwMultisiteSyncPolicyDetailsComponent],
+ imports: [HttpClientTestingModule, ToastrModule.forRoot(), PipesModule]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(RgwMultisiteSyncPolicyDetailsComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, Input, OnChanges, SimpleChanges, TemplateRef, ViewChild } from '@angular/core';
+import { NgbModalRef } from '@ng-bootstrap/ng-bootstrap';
+import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Permission } from '~/app/shared/models/permissions';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Observable, Subscriber, forkJoin as observableForkJoin } from 'rxjs';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
+import { RgwMultisiteSyncFlowModalComponent } from '../rgw-multisite-sync-flow-modal/rgw-multisite-sync-flow-modal.component';
+import { FlowType } from '../models/rgw-multisite';
+
+@Component({
+ selector: 'cd-rgw-multisite-sync-policy-details',
+ templateUrl: './rgw-multisite-sync-policy-details.component.html',
+ styleUrls: ['./rgw-multisite-sync-policy-details.component.scss']
+})
+export class RgwMultisiteSyncPolicyDetailsComponent implements OnChanges {
+ @Input()
+ expandedRow: any;
+ @Input()
+ permission: Permission;
+
+ @ViewChild(TableComponent)
+ table: TableComponent;
+ @ViewChild('deleteTpl', { static: true })
+ deleteTpl: TemplateRef<any>;
+
+ flowType = FlowType;
+ modalRef: NgbModalRef;
+ symmetricalFlowData: any = [];
+ directionalFlowData: any = [];
+ symmetricalFlowCols: CdTableColumn[];
+ directionalFlowCols: CdTableColumn[];
+ symFlowTableActions: CdTableAction[];
+ dirFlowTableActions: CdTableAction[];
+ symFlowSelection = new CdTableSelection();
+ dirFlowSelection = new CdTableSelection();
+
+ constructor(
+ private actionLabels: ActionLabelsI18n,
+ private modalService: ModalService,
+ private rgwMultisiteService: RgwMultisiteService,
+ private taskWrapper: TaskWrapperService
+ ) {
+ this.symmetricalFlowCols = [
+ {
+ name: 'Name',
+ prop: 'id',
+ flexGrow: 1
+ },
+ {
+ name: 'Zones',
+ prop: 'zones',
+ flexGrow: 1
+ }
+ ];
+ this.directionalFlowCols = [
+ {
+ name: 'Source Zone',
+ prop: 'source_zone',
+ flexGrow: 1
+ },
+ {
+ name: 'Destination Zone',
+ prop: 'dest_zone',
+ flexGrow: 1
+ }
+ ];
+ const symAddAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ name: this.actionLabels.CREATE,
+ click: () => this.openModal(FlowType.symmetrical),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ };
+ const symEditAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ name: this.actionLabels.EDIT,
+ click: () => this.openModal(FlowType.symmetrical, true)
+ };
+ const symDeleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ disable: () => !this.symFlowSelection.hasSelection,
+ name: this.actionLabels.DELETE,
+ click: () => this.deleteFlow(FlowType.symmetrical),
+ canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+ };
+ this.symFlowTableActions = [symAddAction, symEditAction, symDeleteAction];
+ const dirAddAction: CdTableAction = {
+ permission: 'create',
+ icon: Icons.add,
+ name: this.actionLabels.CREATE,
+ click: () => this.openModal(FlowType.directional),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ };
+ const dirEditAction: CdTableAction = {
+ permission: 'update',
+ icon: Icons.edit,
+ name: this.actionLabels.EDIT,
+ click: () => this.openModal(FlowType.directional, true),
+ disable: () => true // TODO: disabling 'edit' as we are not getting flow ID from backend which is needed for edit
+ };
+ const dirDeleteAction: CdTableAction = {
+ permission: 'delete',
+ icon: Icons.destroy,
+ disable: () => true, // TODO: disabling 'delete' as we are not getting flow ID from backend which is needed for deletion
+ name: this.actionLabels.DELETE,
+ click: () => this.deleteFlow(FlowType.directional),
+ canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+ };
+ this.dirFlowTableActions = [dirAddAction, dirEditAction, dirDeleteAction];
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.expandedRow.currentValue && changes.expandedRow.currentValue.groupName) {
+ this.symmetricalFlowData = [];
+ this.directionalFlowData = [];
+ this.loadFlowData();
+ }
+ }
+
+ loadFlowData(context?: any) {
+ if (this.expandedRow) {
+ this.rgwMultisiteService
+ .getSyncPolicyGroup(this.expandedRow.groupName, this.expandedRow.bucket)
+ .subscribe(
+ (policy: any) => {
+ this.symmetricalFlowData = policy.data_flow[FlowType.symmetrical] || [];
+ this.directionalFlowData = policy.data_flow[FlowType.directional] || [];
+ },
+ () => {
+ if (context) {
+ context.error();
+ }
+ }
+ );
+ }
+ }
+
+ updateSelection(selection: any, type: FlowType) {
+ if (type === FlowType.directional) {
+ this.dirFlowSelection = selection;
+ } else {
+ this.symFlowSelection = selection;
+ }
+ }
+
+ async openModal(flowType: FlowType, edit = false) {
+ const action = edit ? 'edit' : 'create';
+ const initialState = {
+ groupType: flowType,
+ groupExpandedRow: this.expandedRow,
+ flowSelectedRow:
+ flowType === FlowType.symmetrical
+ ? this.symFlowSelection.first()
+ : this.dirFlowSelection.first(),
+ action: action
+ };
+
+ this.modalRef = this.modalService.show(RgwMultisiteSyncFlowModalComponent, initialState, {
+ size: 'lg'
+ });
+
+ try {
+ const res = await this.modalRef.result;
+ if (res === 'success') {
+ this.loadFlowData();
+ }
+ } catch (err) {}
+ }
+
+ deleteFlow(flowType: FlowType) {
+ let selection = this.symFlowSelection;
+ if (flowType === FlowType.directional) {
+ selection = this.dirFlowSelection;
+ }
+ const flowIds = selection.selected.map((flow: any) => flow.id);
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: selection.hasSingleSelection ? $localize`Flow` : $localize`Flows`,
+ itemNames: flowIds,
+ bodyTemplate: this.deleteTpl,
+ submitActionObservable: () => {
+ return new Observable((observer: Subscriber<any>) => {
+ this.taskWrapper
+ .wrapTaskAroundCall({
+ task: new FinishedTask('rgw/multisite/sync-flow/delete', {
+ flow_ids: flowIds
+ }),
+ call: observableForkJoin(
+ selection.selected.map((flow: any) => {
+ return this.rgwMultisiteService.removeSyncFlow(
+ flow.id,
+ flowType,
+ this.expandedRow.groupName,
+ this.expandedRow.bucket
+ );
+ })
+ )
+ })
+ .subscribe({
+ error: (error: any) => {
+ // Forward the error to the observer.
+ observer.error(error);
+ // Reload the data table content because some deletions might
+ // have been executed successfully in the meanwhile.
+ this.table.refreshBtn();
+ },
+ complete: () => {
+ // Notify the observer that we are done.
+ observer.complete();
+ // Reload the data table content.
+ this.table.refreshBtn();
+ }
+ });
+ });
+ }
+ });
+ }
+}
Multisite Sync Policy
<cd-help-text>
Multisite bucket-granularity sync policy provides fine grained control of data movement between
- buckets in different zones.
+ buckets in different zones. Leveraging the bucket-granularity sync policy is possible for buckets to diverge,
+ and a bucket can pull data from other buckets (ones that don’t share its name or its ID) in different zone.
</cd-help-text>
</legend>
<cd-table
[columns]="columns"
identifier="uniqueId"
[forceIdentifier]="true"
- columnMode="flex"
selectionType="multiClick"
[searchableObjects]="true"
- [hasDetails]="false"
- [serverSide]="false"
- [count]="0"
- [maxLimit]="25"
+ [hasDetails]="true"
[toolHeader]="true"
+ (setExpandedRow)="setExpandedRow($event)"
(fetchData)="getPolicyList($event)"
(updateSelection)="updateSelection($event)">
<div class="table-actions btn-toolbar">
[tableActions]="tableActions">
</cd-table-actions>
</div>
+ <cd-rgw-multisite-sync-policy-details
+ cdTableDetail
+ [expandedRow]="expandedRow"
+ [permission]="permission">
+ </cd-rgw-multisite-sync-policy-details>
</cd-table>
<ng-template #deleteTpl>
import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
import { forkJoin as observableForkJoin, Observable, Subscriber } from 'rxjs';
import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
styleUrls: ['./rgw-multisite-sync-policy.component.scss'],
providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
})
-export class RgwMultisiteSyncPolicyComponent implements OnInit {
+export class RgwMultisiteSyncPolicyComponent extends ListWithDetails implements OnInit {
@ViewChild(TableComponent, { static: true })
table: TableComponent;
@ViewChild('deleteTpl', { static: true })
private authStorageService: AuthStorageService,
private modalService: ModalService,
private taskWrapper: TaskWrapperService
- ) {}
+ ) {
+ super();
+ }
ngOnInit(): void {
this.permission = this.authStorageService.getPermissions().rgw;
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';
+import { RgwMultisiteSyncPolicyDetailsComponent } from './rgw-multisite-sync-policy-details/rgw-multisite-sync-policy-details.component';
+import { RgwMultisiteSyncFlowModalComponent } from './rgw-multisite-sync-flow-modal/rgw-multisite-sync-flow-modal.component';
@NgModule({
imports: [
RgwMultisiteSyncPolicyFormComponent,
RgwConfigDetailsComponent,
RgwConfigurationPageComponent,
- RgwMultisiteWizardComponent
+ RgwMultisiteWizardComponent,
+ RgwMultisiteSyncPolicyDetailsComponent,
+ RgwMultisiteSyncFlowModalComponent
],
providers: [TitleCasePipe]
})
expect(req.request.method).toBe('GET');
req.flush(mockSyncPolicyData[1]);
});
+
+ it('should create Symmetrical Sync flow', () => {
+ const payload = {
+ group_id: 'test',
+ bucket_name: 'test',
+ flow_type: 'symmetrical',
+ flow_id: 'new-flow',
+ zones: ['zone1-zg1-realm1']
+ };
+ service.createEditSyncFlow(payload).subscribe();
+ const req = httpTesting.expectOne('api/rgw/multisite/sync-flow');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(payload);
+ req.flush(null);
+ });
+
+ it('should create Directional Sync flow', () => {
+ const payload = {
+ group_id: 'test',
+ bucket_name: 'test',
+ flow_type: 'directional',
+ flow_id: 'new-flow',
+ source_zone: ['zone1-zg1-realm1'],
+ destination_zone: ['zone1-zg2-realm2']
+ };
+ service.createEditSyncFlow(payload).subscribe();
+ const req = httpTesting.expectOne('api/rgw/multisite/sync-flow');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(payload);
+ req.flush(null);
+ });
+
+ it('should edit Symmetrical Sync flow', () => {
+ const payload = {
+ group_id: 'test',
+ bucket_name: 'test',
+ flow_type: 'symmetrical',
+ flow_id: 'new-flow',
+ zones: ['zone1-zg1-realm1', 'zone2-zg1-realm1']
+ };
+ service.createEditSyncFlow(payload).subscribe();
+ const req = httpTesting.expectOne('api/rgw/multisite/sync-flow');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(payload);
+ req.flush(null);
+ });
+
+ it('should edit Directional Sync flow', () => {
+ const payload = {
+ group_id: 'test',
+ bucket_name: 'test',
+ flow_type: 'directional',
+ flow_id: 'new-flow',
+ source_zone: ['zone1-zg1-realm1'],
+ destination_zone: ['zone1-zg2-realm2', 'zone2-zg2-realm2']
+ };
+ service.createEditSyncFlow(payload).subscribe();
+ const req = httpTesting.expectOne('api/rgw/multisite/sync-flow');
+ expect(req.request.method).toBe('PUT');
+ expect(req.request.body).toEqual(payload);
+ req.flush(null);
+ });
+
+ it('should remove Symmetrical Sync flow', () => {
+ service.removeSyncFlow('test', 'symmetrical', 'test', 'new-bucket').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/multisite/sync-flow/test/symmetrical/test?bucket_name=new-bucket`
+ );
+ expect(req.request.method).toBe('DELETE');
+ req.flush(null);
+ });
+
+ it('should remove Directional Sync flow', () => {
+ service.removeSyncFlow('test', 'directional', 'test', 'new-bucket').subscribe();
+ const req = httpTesting.expectOne(
+ `api/rgw/multisite/sync-flow/test/directional/test?bucket_name=new-bucket`
+ );
+ expect(req.request.method).toBe('DELETE');
+ req.flush(null);
+ });
});
return this.http.post(`${this.uiUrl}/multisite-replications`, null, { params: params });
}
+
+ createEditSyncFlow(payload: any) {
+ return this.http.put(`${this.url}/sync-flow`, payload);
+ }
+
+ removeSyncFlow(flow_id: string, flow_type: string, group_id: string, bucket_name?: string) {
+ let params = new HttpParams();
+ if (bucket_name) {
+ params = params.append('bucket_name', encodeURIComponent(bucket_name));
+ }
+ return this.http.delete(
+ `${this.url}/sync-flow/${encodeURIComponent(flow_id)}/${flow_type}/${encodeURIComponent(
+ group_id
+ )}`,
+ { params }
+ );
+ }
}
}`;
}
),
+ 'rgw/multisite/sync-flow/delete': this.newTaskMessage(
+ this.commonOperations.delete,
+ (metadata) => {
+ return $localize`${
+ metadata.flow_ids.length > 1
+ ? 'selected Flow Names'
+ : `Flow Name '${metadata.flow_ids[0]}'`
+ }`;
+ }
+ ),
// iSCSI target tasks
'iscsi/target/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.iscsiTarget(metadata)
default: ''
type: string
destination_zone:
- default: ''
type: string
flow_id:
type: string
group_id:
type: string
source_zone:
- default: ''
type: string
zones:
type: string
def create_sync_flow(self, group_id: str, flow_id: str, flow_type: str,
zones: Optional[List[str]] = None, bucket_name: str = '',
- source_zone: str = '', destination_zone: str = ''):
+ source_zone: Optional[List[str]] = None,
+ destination_zone: Optional[List[str]] = None):
rgw_sync_policy_cmd = ['sync', 'group', 'flow', 'create', '--group-id', group_id,
'--flow-id', flow_id, '--flow-type', SyncFlowTypes[flow_type].value]
if SyncFlowTypes[flow_type].value == 'directional':
- rgw_sync_policy_cmd += ['--source-zone', source_zone, '--dest-zone', destination_zone]
+ if source_zone is not None:
+ rgw_sync_policy_cmd += ['--source-zone', ','.join(source_zone)]
+ if destination_zone is not None:
+ rgw_sync_policy_cmd += ['--dest-zone', ','.join(destination_zone)]
else:
if zones:
rgw_sync_policy_cmd += ['--zones', ','.join(zones)]