Merge pull request #66607 from rhcs-dashboard/carbonized-import-modal

mgr/dashboard: carbonized-import-modal

Reviewed-by: Nizamudeen A <nia@redhat.com>
Reviewed-by: Devika Babrekar <devika.babrekar@ibm.com>
This commit is contained in:
Afreen Misbah 2026-07-22 13:20:35 +05:30 committed by GitHub
commit df499f3134
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 331 additions and 303 deletions

View File

@ -194,9 +194,7 @@ export class RgwMultisiteDetailsComponent extends CdForm implements OnDestroy, O
const initialState = {
multisiteInfo: this.multisiteInfo
};
this.bsModalRef = this.modalService.show(RgwMultisiteImportComponent, initialState, {
size: 'lg'
});
this.cdsModalService.show(RgwMultisiteImportComponent, initialState);
}
openExportModal() {

View File

@ -1,155 +1,157 @@
<cd-modal [modalRef]="activeModal">
<ng-container
i18n="form title"
class="modal-title"
>Import Multi-site Token</ng-container
>
<cds-modal
size="lg"
[open]="open"
[hasScrollingContent]="true"
(overlaySelected)="closeModal()"
>
<cds-modal-header (closeSelect)="closeModal()">
<h3
cdsModalHeaderHeading
i18n
>
Import multi-site tokens
</h3>
</cds-modal-header>
<ng-container class="modal-content">
<div
cdsModalContent
[hasForm]="true"
>
<form
name="importTokenForm"
#frm="ngForm"
[formGroup]="importTokenForm"
novalidate
>
<div class="modal-body">
<cd-alert-panel
type="info"
spacingClass="mb-3"
>
<div class="form-item">
<cd-alert-panel type="info">
<ul>
<li>
<li i18n>
This feature allows you to configure a connection between your primary and secondary
Ceph clusters for data replication. By importing a token, you establish a link between
the clusters, enabling data synchronization.
</li>
<li>
<li i18n>
To obtain the token, generate it from your primary Ceph cluster. This token includes
encoded information about the primary cluster's endpoint, access key, and secret key.
</li>
<li>
<li i18n>
The secondary zone represents the destination cluster where your data will be
replicated.
</li>
</ul>
</cd-alert-panel>
<legend i18n>Zone Details</legend>
<div class="form-group row">
<label
class="cd-col-form-label required"
for="realmToken"
i18n
>Token
</label>
<div class="cd-col-form-input">
<input
id="realmToken"
name="realmToken"
class="form-control"
type="text"
formControlName="realmToken"
/>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('realmToken', frm, 'required')"
i18n
>This field is required.</span
>
</div>
</div>
<div class="form-group row">
<label
class="cd-col-form-label required"
for="zoneName"
i18n
>Secondary Zone Name</label
>
<div class="cd-col-form-input">
<input
class="form-control"
type="text"
placeholder="Zone name..."
id="zoneName"
name="zoneName"
formControlName="zoneName"
/>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('zoneName', frm, 'required')"
i18n
>This field is required.</span
>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('zoneName', frm, 'uniqueName')"
i18n
>The chosen zone name is already in use.</span
>
</div>
</div>
<div class="form-group row">
<div class="cd-col-form-offset">
<div class="custom-control custom-checkbox">
<input
class="custom-control-input"
id="archive_zone"
type="checkbox"
formControlName="archive_zone"
/>
<label
class="custom-control-label cds-ml-2"
for="archive_zone"
i18n
>Archive</label
>
<cd-helper i18n>
<span
>Enable archival storage to keep all object versions and protect data from
deletion or corruption.</span
>
</cd-helper>
</div>
</div>
</div>
<legend i18n>Service Details</legend>
<div class="form-group row">
<div class="cd-col-form-offset">
<div class="custom-control custom-checkbox">
<input
class="custom-control-input"
id="unmanaged"
type="checkbox"
formControlName="unmanaged"
/>
<label
class="custom-control-label cds-ml-2"
for="unmanaged"
i18n
>Unmanaged</label
>
<cd-helper i18n
>If set to true, the orchestrator will not start nor stop any daemon associated with
this service. Placement and all other properties will be ignored.</cd-helper
>
</div>
</div>
</div>
</div>
<legend
class="cd-header"
i18n
>
Zone Details
</legend>
<div class="form-item">
<cds-text-label
labelInputID="realmToken"
[invalid]="realmTokenRef.isInvalid"
[invalidText]="realmTokenError"
cdRequiredField="Token"
i18n
>Token
<input
cdsText
cdValidate
#realmTokenRef="cdValidate"
type="text"
id="realmToken"
name="realmToken"
formControlName="realmToken"
modal-primary-focus
[invalid]="realmTokenRef.isInvalid"
/>
</cds-text-label>
<ng-template #realmTokenError>
<ng-container
*ngTemplateOutlet="
validationErrors;
context: { control: importTokenForm.get('realmToken') }
"
></ng-container>
</ng-template>
</div>
<!-- Placement -->
<div
*ngIf="!importTokenForm.controls.unmanaged.value"
class="form-group row"
<div class="form-item">
<cds-text-label
labelInputID="zoneName"
[invalid]="zoneNameRef.isInvalid"
[invalidText]="zoneNameError"
cdRequiredField="Secondary Zone Name"
i18n
>Secondary Zone Name
<input
cdsText
cdValidate
#zoneNameRef="cdValidate"
type="text"
placeholder="Zone name..."
id="zoneName"
name="zoneName"
formControlName="zoneName"
[invalid]="zoneNameRef.isInvalid"
/>
</cds-text-label>
<ng-template #zoneNameError>
<ng-container
*ngTemplateOutlet="
validationErrors;
context: { control: importTokenForm.get('zoneName') }
"
></ng-container>
</ng-template>
</div>
<div class="form-item">
<cds-checkbox
id="archive_zone"
formControlName="archive_zone"
>
<label
class="cd-col-form-label"
for="placement"
i18n
>Placement</label
<span i18n>Archive</span>
<cd-help-text i18n
>Enable archival storage to keep all object versions and protect data from deletion or
corruption.</cd-help-text
>
<div class="cd-col-form-input">
<select
id="placement"
class="form-select"
</cds-checkbox>
</div>
<legend
class="cd-header"
i18n
>
Service Details
</legend>
<div class="form-item">
<cds-checkbox
id="unmanaged"
formControlName="unmanaged"
>
<span i18n>Unmanaged</span>
<cd-help-text i18n
>If set to true, the orchestrator will not start nor stop any daemon associated with
this service. Placement and all other properties will be ignored.</cd-help-text
>
</cds-checkbox>
</div>
<!-- Placement -->
@if (!importTokenForm.controls.unmanaged.value) {
<div
class="form-item"
cdsRow
>
<div cdsCol>
<cds-select
formControlName="placement"
label="Placement"
i18n-label
id="placement"
>
<option
i18n
@ -163,151 +165,158 @@
>
Label
</option>
</select>
</cds-select>
</div>
</div>
}
<!-- Label -->
<!-- Label -->
@if (
!importTokenForm.controls.unmanaged.value &&
importTokenForm.controls.placement.value === 'label'
) {
<div
*ngIf="
!importTokenForm.controls.unmanaged.value &&
importTokenForm.controls.placement.value === 'label'
"
class="form-group row"
class="form-item"
cdsRow
>
<label
i18n
class="cd-col-form-label"
for="label"
>Label</label
>
<div class="cd-col-form-input">
<input
id="label"
class="form-control"
type="text"
<div cdsCol>
<cds-combo-box
label="Label"
type="single"
formControlName="label"
[ngbTypeahead]="searchLabels"
(focus)="labelFocus.next($any($event).target.value)"
(click)="labelClick.next($any($event).target.value)"
/>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('label', frm, 'required')"
i18n
>This field is required.</span
i18n-label
[items]="labels"
[appendInline]="true"
id="label"
[invalid]="importTokenForm.showError('label', frm)"
[invalidText]="labelError"
selectionFeedback="top-after-reopen"
placeholder="Select label..."
i18n-placeholder
>
<cds-dropdown-list></cds-dropdown-list>
</cds-combo-box>
<ng-template #labelError>
<ng-container
*ngTemplateOutlet="
validationErrors;
context: { control: importTokenForm.get('label') }
"
></ng-container>
</ng-template>
</div>
</div>
}
<!-- Hosts -->
<!-- Hosts -->
@if (
!importTokenForm.controls.unmanaged.value &&
importTokenForm.getValue('placement') === 'hosts'
) {
<div
*ngIf="
!importTokenForm.controls.unmanaged.value &&
importTokenForm.controls.placement.value === 'hosts'
"
class="form-group row"
class="form-item"
cdsRow
>
<label
class="cd-col-form-label"
for="hosts"
i18n
>Hosts</label
>
<div class="cd-col-form-input">
<cd-select-badges
<div cdsCol>
<cds-combo-box
label="Hosts"
type="multi"
formControlName="hosts"
id="hosts"
[data]="importTokenForm.controls.hosts.value"
[options]="hosts.options"
[messages]="hosts.messages"
name="hosts"
[items]="hosts"
[appendInline]="true"
selectionFeedback="top-after-reopen"
(selected)="multiSelector($event)"
placeholder="Select hosts..."
i18n-placeholder
>
</cd-select-badges>
<cds-dropdown-list></cds-dropdown-list>
</cds-combo-box>
</div>
</div>
}
<!-- count -->
<!-- count -->
@if (!importTokenForm.controls.unmanaged.value) {
<div
*ngIf="!importTokenForm.controls.unmanaged.value"
class="form-group row"
class="form-item"
cdsRow
>
<label
class="cd-col-form-label"
for="count"
>
<span i18n>Count</span>
<cd-helper i18n>Only that number of daemons will be created.</cd-helper>
</label>
<div class="cd-col-form-input">
<input
id="count"
class="form-control"
type="number"
<div cdsCol>
<cds-number
cdValidate
#countRef="cdValidate"
formControlName="count"
min="1"
/>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('count', frm, 'min')"
i18n
>The value must be at least 1.</span
>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('count', frm, 'pattern')"
i18n
>The entered value needs to be a number.</span
>
label="Count"
i18n-label
[min]="1"
[invalid]="countRef.isInvalid"
[invalidText]="countError"
[helperText]="countHelper"
></cds-number>
<ng-template #countHelper>
<span i18n>Only that number of daemons will be created.</span>
</ng-template>
<ng-template #countError>
<ng-container
*ngTemplateOutlet="
validationErrors;
context: { control: importTokenForm.get('count') }
"
></ng-container>
</ng-template>
</div>
</div>
}
<!-- RGW -->
<ng-container *ngIf="!importTokenForm.controls.unmanaged.value">
<!-- rgw_frontend_port -->
<div class="form-group row">
<label
i18n
class="cd-col-form-label"
for="rgw_frontend_port"
>Port</label
>
<div class="cd-col-form-input">
<input
id="rgw_frontend_port"
class="form-control"
type="number"
formControlName="rgw_frontend_port"
min="1"
max="65535"
/>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('rgw_frontend_port', frm, 'pattern')"
i18n
>The entered value needs to be a number.</span
>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('rgw_frontend_port', frm, 'min')"
i18n
>The value must be at least 1.</span
>
<span
class="invalid-feedback"
*ngIf="importTokenForm.showError('rgw_frontend_port', frm, 'max')"
i18n
>The value cannot exceed 65535.</span
>
</div>
<!-- RGW -->
<ng-container *ngIf="!importTokenForm.controls.unmanaged.value">
<!-- rgw_frontend_port -->
<div
class="form-item"
cdsRow
>
<div cdsCol>
<cds-number
cdValidate
#portRef="cdValidate"
formControlName="rgw_frontend_port"
label="Port"
i18n-label
[min]="1"
[max]="65535"
[invalid]="portRef.isInvalid"
[invalidText]="portError"
></cds-number>
<ng-template #portError>
<ng-container
*ngTemplateOutlet="
validationErrors;
context: { control: importTokenForm.get('rgw_frontend_port') }
"
></ng-container>
</ng-template>
</div>
</ng-container>
</div>
<div class="modal-footer">
<cd-form-button-panel
(submitActionEvent)="onSubmit()"
[submitText]="actionLabels.IMPORT"
[form]="importTokenForm"
></cd-form-button-panel>
</div>
</div>
</ng-container>
</form>
</ng-container>
</cd-modal>
</div>
<cd-form-button-panel
(submitActionEvent)="onSubmit()"
[submitText]="actionLabels.IMPORT"
[form]="importTokenForm"
[modalForm]="true"
></cd-form-button-panel>
<ng-template
#validationErrors
let-control="control"
>
@if (control?.errors) {
@for (err of control.errors | keyvalue; track err.key) {
<span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
}
}
</ng-template>
</cds-modal>

View File

@ -3,7 +3,15 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { RouterTestingModule } from '@angular/router/testing';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import {
ButtonModule,
CheckboxModule,
ComboBoxModule,
InputModule,
ModalModule,
NumberModule,
SelectModule
} from 'carbon-components-angular';
import { SharedModule } from '~/app/shared/shared.module';
import { RgwMultisiteImportComponent } from './rgw-multisite-import.component';
@ -14,7 +22,19 @@ describe('RgwMultisiteImportComponent', () => {
let fixture: ComponentFixture<RgwMultisiteImportComponent>;
configureTestBed({
imports: [SharedModule, ReactiveFormsModule, RouterTestingModule, HttpClientTestingModule],
imports: [
SharedModule,
ReactiveFormsModule,
RouterTestingModule,
HttpClientTestingModule,
ModalModule,
InputModule,
CheckboxModule,
ComboBoxModule,
SelectModule,
NumberModule,
ButtonModule
],
declarations: [RgwMultisiteImportComponent],
providers: [NgbActiveModal]
});

View File

@ -1,7 +1,10 @@
import { Component, OnInit, ViewChild } from '@angular/core';
import { Component, OnInit } from '@angular/core';
import { BaseModal } from 'carbon-components-angular';
import { FormControl, Validators } from '@angular/forms';
import { NgbActiveModal, NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
import { RgwRealmService } from '~/app/shared/api/rgw-realm.service';
import { ComboBoxItem } from '~/app/shared/models/combo-box.model';
import { HostService } from '~/app/shared/api/host.service';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
@ -9,11 +12,6 @@ import { CdValidators } from '~/app/shared/forms/cd-validators';
import { NotificationService } from '~/app/shared/services/notification.service';
import { RgwZone } from '../models/rgw-multisite';
import _ from 'lodash';
import { SelectMessages } from '~/app/shared/components/select/select-messages.model';
import { HostService } from '~/app/shared/api/host.service';
import { SelectOption } from '~/app/shared/components/select/select-option.model';
import { Observable, Subject, merge } from 'rxjs';
import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators';
@Component({
selector: 'cd-rgw-multisite-import',
@ -21,22 +19,26 @@ import { debounceTime, distinctUntilChanged, filter, map } from 'rxjs/operators'
styleUrls: ['./rgw-multisite-import.component.scss'],
standalone: false
})
export class RgwMultisiteImportComponent implements OnInit {
export class RgwMultisiteImportComponent extends BaseModal implements OnInit {
readonly endpoints = /^((https?:\/\/)|(www.))(?:([a-zA-Z]+)|(\d+\.\d+.\d+.\d+)):\d{2,4}$/;
readonly ipv4Rgx =
/^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/i;
readonly ipv6Rgx = /^(?:[a-f0-9]{1,4}:){7}[a-f0-9]{1,4}$/i;
@ViewChild(NgbTypeahead, { static: false })
typeahead: NgbTypeahead;
importTokenForm: CdFormGroup;
multisiteInfo: object[] = [];
zoneList: RgwZone[] = [];
zoneNames: string[];
hosts: any;
labels: string[];
labelClick = new Subject<string>();
labelFocus = new Subject<string>();
hosts: ComboBoxItem[] = [];
labels: ComboBoxItem[] = [];
selectedHosts: string[] = [];
INVALID_TEXTS = {
required: $localize`This field is required.`,
uniqueName: $localize`The chosen zone name is already in use.`,
min: $localize`The value must be at least 1.`,
max: $localize`The value cannot exceed 65535.`,
pattern: $localize`The entered value needs to be a number.`
};
constructor(
public activeModal: NgbActiveModal,
@ -46,13 +48,7 @@ export class RgwMultisiteImportComponent implements OnInit {
public actionLabels: ActionLabelsI18n,
public notificationService: NotificationService
) {
this.hosts = {
options: [],
messages: new SelectMessages({
empty: $localize`There are no hosts.`,
filter: $localize`Filter hosts`
})
};
super();
this.createForm();
}
ngOnInit(): void {
@ -64,17 +60,24 @@ export class RgwMultisiteImportComponent implements OnInit {
return zone['name'];
});
this.hostService.getAllHosts().subscribe((resp: object[]) => {
const options: SelectOption[] = [];
const options: ComboBoxItem[] = [];
_.forEach(resp, (host: object) => {
if (_.get(host, 'sources.orchestrator', false)) {
const option = new SelectOption(false, _.get(host, 'hostname'), '');
options.push(option);
const hostname = _.get(host, 'hostname');
options.push({ content: hostname, selected: false, name: hostname });
}
});
this.hosts.options = [...options];
this.hosts = [...options];
});
this.hostService.getLabels().subscribe((resp: string[]) => {
this.labels = resp;
this.labels = resp.map((label) => {
return {
content: label,
selected: false,
name: label
};
});
});
}
@ -92,7 +95,12 @@ export class RgwMultisiteImportComponent implements OnInit {
]
}),
rgw_frontend_port: new FormControl(null, {
validators: [Validators.required, Validators.pattern('^[0-9]*$')]
validators: [
CdValidators.requiredIf({
unmanaged: false
}),
Validators.pattern('^[0-9]*$')
]
}),
placement: new FormControl('hosts'),
label: new FormControl(null, [
@ -108,6 +116,10 @@ export class RgwMultisiteImportComponent implements OnInit {
});
}
multiSelector(event: ComboBoxItem[]) {
this.selectedHosts = event.map((host: ComboBoxItem) => host.content);
}
onSubmit() {
const values = this.importTokenForm.value;
const placementSpec: object = {
@ -117,18 +129,21 @@ export class RgwMultisiteImportComponent implements OnInit {
if (!values['unmanaged']) {
switch (values['placement']) {
case 'hosts':
if (values['hosts'].length > 0) {
placementSpec['placement']['hosts'] = values['hosts'];
if (this.selectedHosts.length > 0) {
placementSpec['placement']['hosts'] = this.selectedHosts;
}
break;
case 'label':
placementSpec['placement']['label'] = values['label'];
if (!_.isEmpty(values['label'])) {
placementSpec['placement']['label'] = values['label']?.content;
}
break;
}
if (_.isNumber(values['count']) && values['count'] > 0) {
placementSpec['placement']['count'] = values['count'];
}
}
this.rgwRealmService
.importRealmToken(
values['realmToken'],
@ -143,25 +158,11 @@ export class RgwMultisiteImportComponent implements OnInit {
NotificationType.success,
$localize`Realm token import successfull`
);
this.activeModal.close();
this.closeModal();
},
() => {
this.importTokenForm.setErrors({ cdSubmitButton: true });
}
);
}
searchLabels = (text$: Observable<string>) => {
return merge(
text$.pipe(debounceTime(200), distinctUntilChanged()),
this.labelFocus,
this.labelClick.pipe(filter(() => !this.typeahead.isPopupOpen()))
).pipe(
map((value) =>
this.labels
.filter((label: string) => label.toLowerCase().indexOf(value.toLowerCase()) > -1)
.slice(0, 10)
)
);
};
}