Merge pull request #70344 from rhcs-dashboard/fix-78423-main

mgr/dashboard: fix rgw service form realm/zg/zone selection

Reviewed-by: Afreen Misbah <afreen@ibm.com>
This commit is contained in:
Afreen Misbah 2026-07-21 17:02:45 +05:30 committed by GitHub
commit dde3f89434
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 383 additions and 19 deletions

View File

@ -304,7 +304,7 @@
-- No realm available --
</option>
}
@for (realm of realmList; track realm) {
@for (realm of realmList; track realm.name) {
<option [value]="realm.name">
{{ realm.name }}
</option>
@ -327,7 +327,7 @@
formControlName="zonegroup_name"
label="Zonegroup"
>
@for (zonegroup of zonegroupList; track zonegroup) {
@for (zonegroup of filteredZonegroupList; track zonegroup.name) {
<option [value]="zonegroup.name">
{{ zonegroup.name }}
</option>
@ -341,13 +341,24 @@
formControlName="zone_name"
label="Zone"
>
@for (zone of zoneList; track zone) {
@for (zone of filteredZoneList; track zone.name) {
<option [value]="zone.name">
{{ zone.name }}
</option>
}
</cds-select>
</div>
@if (editing && showRgwRealmChangedInfo) {
<cd-alert-panel
type="info"
spacingClass="mb-3"
i18n
>
The RGW daemons for this service must be restarted to apply the updated realm,
zonegroup, or zone configuration.
</cd-alert-panel>
}
}
<!-- unmanaged -->

View File

@ -1,5 +1,5 @@
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { RouterTestingModule } from '@angular/router/testing';
@ -11,6 +11,10 @@ import { of } from 'rxjs';
import { CephServiceService } from '~/app/shared/api/ceph-service.service';
import { PaginateObservable } from '~/app/shared/api/paginate.model';
import { RgwRealmService } from '~/app/shared/api/rgw-realm.service';
import { RgwZonegroupService } from '~/app/shared/api/rgw-zonegroup.service';
import { RgwZoneService } from '~/app/shared/api/rgw-zone.service';
import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { SharedModule } from '~/app/shared/shared.module';
import { configureTestBed, FormHelper, Mocks } from '~/testing/unit-test-helper';
@ -879,4 +883,243 @@ x4Ea7kGVgx9kWh5XjWz9wjZvY49UKIT5ppIAWPMbLl3UpfckiuNhTA==
});
});
});
// ---------------------------------------------------------------------------
// RGW multisite filtering and subscription behaviour
// ---------------------------------------------------------------------------
describe('RGW multisite filtering', () => {
// Shared multisite fixture data
const realmA = { id: 'r1', name: 'realm-a' };
const realmB = { id: 'r2', name: 'realm-b' };
const zgA1 = { id: 'zg1', name: 'zg-a1', realm_id: 'r1', zones: [{ name: 'zone-a1' }] };
const zgA2 = { id: 'zg2', name: 'zg-a2', realm_id: 'r1', zones: [{ name: 'zone-a2' }] };
const zgB1 = { id: 'zg3', name: 'zg-b1', realm_id: 'r2', zones: [{ name: 'zone-b1' }] };
const zoneA1 = { id: 'z1', name: 'zone-a1' };
const zoneA2 = { id: 'z2', name: 'zone-a2' };
const zoneB1 = { id: 'z3', name: 'zone-b1' };
const realmsInfo = {
realms: [realmA, realmB],
default_realm: 'r1'
};
const zonegroupsInfo = {
zonegroups: [zgA1, zgA2, zgB1],
default_zonegroup: 'zg1'
};
const zonesInfo = {
zones: [zoneA1, zoneA2, zoneB1],
default_zone: 'z1'
};
let rgwRealmService: RgwRealmService;
let rgwZonegroupService: RgwZonegroupService;
let rgwZoneService: RgwZoneService;
let rgwMultisiteService: RgwMultisiteService;
beforeEach(() => {
rgwRealmService = TestBed.inject(RgwRealmService);
rgwZonegroupService = TestBed.inject(RgwZonegroupService);
rgwZoneService = TestBed.inject(RgwZoneService);
rgwMultisiteService = TestBed.inject(RgwMultisiteService);
spyOn(rgwRealmService, 'getAllRealmsInfo').and.returnValue(of(realmsInfo));
spyOn(rgwZonegroupService, 'getAllZonegroupsInfo').and.returnValue(of(zonegroupsInfo));
spyOn(rgwZoneService, 'getAllZonesInfo').and.returnValue(of(zonesInfo));
spyOn(rgwMultisiteService, 'getRgwModuleStatus').and.returnValue(of(false));
});
describe('create mode initial filtering', () => {
beforeEach(fakeAsync(() => {
formHelper.setValue('service_type', 'rgw');
component.setRgwFields();
tick();
}));
it('should populate filteredZonegroupList with all zonegroups when no realm is selected', () => {
// Default realm-a is selected; its two zonegroups should appear.
expect(component.filteredZonegroupList.length).toBe(2);
expect(component.filteredZonegroupList.map((zg) => zg.name)).toEqual(
jasmine.arrayContaining(['zg-a1', 'zg-a2'])
);
});
it('should populate filteredZoneList based on the default zonegroup', () => {
// Default zonegroup is zg-a1 which has zone-a1.
expect(component.filteredZoneList.length).toBe(1);
expect(component.filteredZoneList[0].name).toBe('zone-a1');
});
});
describe('realm change cascades zonegroup and zone lists', () => {
beforeEach(fakeAsync(() => {
formHelper.setValue('service_type', 'rgw');
component.setRgwFields();
tick();
}));
it('switching realm filters zonegroups to the new realm', fakeAsync(() => {
form.get('realm_name').setValue('realm-b');
tick();
expect(component.filteredZonegroupList.length).toBe(1);
expect(component.filteredZonegroupList[0].name).toBe('zg-b1');
}));
it('switching realm auto-cascades zonegroup then zone for the new realm', fakeAsync(() => {
form.get('realm_name').setValue('realm-b');
tick();
// The subscription chain (realm → zonegroup → zone) resolves within the same
// tick: realm-b's only zonegroup (zg-b1) is auto-selected, then its first zone
// (zone-b1) is auto-selected. zone_name should be non-null and point to
// the first zone of the new realm's first zonegroup.
expect(form.get('zone_name').value).toBe('zone-b1');
}));
it('switching realm then zonegroup populates zones for the new zonegroup', fakeAsync(() => {
form.get('realm_name').setValue('realm-b');
tick();
// The first zonegroup of realm-b (zg-b1) is auto-selected by the subscription.
tick();
expect(component.filteredZoneList.length).toBe(1);
expect(component.filteredZoneList[0].name).toBe('zone-b1');
}));
});
describe('zonegroup change cascades zone list', () => {
beforeEach(fakeAsync(() => {
formHelper.setValue('service_type', 'rgw');
component.setRgwFields();
tick();
}));
it('switching zonegroup within the same realm updates filteredZoneList', fakeAsync(() => {
form.get('zonegroup_name').setValue('zg-a2');
tick();
expect(component.filteredZoneList.length).toBe(1);
expect(component.filteredZoneList[0].name).toBe('zone-a2');
}));
it('auto-selects the first zone of the new zonegroup', fakeAsync(() => {
form.get('zonegroup_name').setValue('zg-a2');
tick();
expect(form.get('zone_name').value).toBe('zone-a2');
}));
});
describe('edit mode initial population from spec', () => {
beforeEach(fakeAsync(() => {
component.editing = true;
component.setRgwFields('realm-a', 'zg-a2', 'zone-a2');
tick();
}));
it('should set filteredZonegroupList to realm-a zonegroups', () => {
expect(component.filteredZonegroupList.map((zg) => zg.name)).toEqual(
jasmine.arrayContaining(['zg-a1', 'zg-a2'])
);
});
it('should set filteredZoneList based on the spec zonegroup', () => {
expect(component.filteredZoneList.length).toBe(1);
expect(component.filteredZoneList[0].name).toBe('zone-a2');
});
it('should not show the realm-changed banner immediately after loading', () => {
expect(component.showRgwRealmChangedInfo).toBe(false);
});
});
describe('edit mode realm-changed banner (showRgwRealmChangedInfo)', () => {
beforeEach(fakeAsync(() => {
component.editing = true;
component.setRgwFields('realm-a', 'zg-a1', 'zone-a1');
tick();
// Banner must be false right after initial load.
expect(component.showRgwRealmChangedInfo).toBe(false);
}));
it('shows the banner when the realm is changed', fakeAsync(() => {
form.get('realm_name').setValue('realm-b');
tick();
form.get('zonegroup_name').setValue('zg-b1');
tick();
form.get('zone_name').setValue('zone-b1');
tick();
expect(component.showRgwRealmChangedInfo).toBe(true);
}));
it('shows the banner when only the zonegroup is changed', fakeAsync(() => {
form.get('zonegroup_name').setValue('zg-a2');
tick();
form.get('zone_name').setValue('zone-a2');
tick();
expect(component.showRgwRealmChangedInfo).toBe(true);
}));
it('shows the banner when only the zone is changed', fakeAsync(() => {
// Add a second zone to zg-a1 so there is something to switch to
component.filteredZoneList = [
{ id: 'z1', name: 'zone-a1' } as any,
{ id: 'z4', name: 'zone-a1-extra' } as any
];
form.get('zone_name').setValue('zone-a1-extra');
tick();
expect(component.showRgwRealmChangedInfo).toBe(true);
}));
it('hides the banner when reverted back to original values', fakeAsync(() => {
// Change then revert
form.get('zonegroup_name').setValue('zg-a2');
tick();
form.get('zonegroup_name').setValue('zg-a1');
tick();
form.get('zone_name').setValue('zone-a1');
tick();
expect(component.showRgwRealmChangedInfo).toBe(false);
}));
});
describe('no-realm scenario filteredZoneList falls back to all zones', () => {
const noRealmZonegroupsInfo = {
zonegroups: [{ id: 'zg0', name: 'default', realm_id: null, zones: [] }],
default_zonegroup: 'zg0'
};
const noRealmZonesInfo = {
zones: [{ id: 'z0', name: 'default' }],
default_zone: 'z0'
};
beforeEach(fakeAsync(() => {
(rgwZonegroupService.getAllZonegroupsInfo as jasmine.Spy).and.returnValue(
of(noRealmZonegroupsInfo)
);
(rgwZoneService.getAllZonesInfo as jasmine.Spy).and.returnValue(of(noRealmZonesInfo));
(rgwRealmService.getAllRealmsInfo as jasmine.Spy).and.returnValue(
of({ realms: [], default_realm: null })
);
component.setRgwFields();
tick();
}));
it('should show realm creation form when no realms exist', () => {
expect(component.showRealmCreationForm).toBe(true);
});
it('should fall back to all zones when the zonegroup has no zones list', fakeAsync(() => {
form.get('zonegroup_name').setValue('default');
tick();
expect(component.filteredZoneList.length).toBeGreaterThan(0);
}));
});
});
});

View File

@ -8,7 +8,7 @@ import { NgbTypeahead } from '@ng-bootstrap/ng-bootstrap';
import { ListItem } from 'carbon-components-angular';
import _ from 'lodash';
import { forkJoin, Observable, Subject, Subscription } from 'rxjs';
import { map } from 'rxjs/operators';
import { distinctUntilChanged, filter, map } from 'rxjs/operators';
import { Pool } from '~/app/ceph/pool/pool';
import { CreateRgwServiceEntitiesComponent } from '~/app/ceph/rgw/create-rgw-service-entities/create-rgw-service-entities.component';
import { RgwRealm, RgwZonegroup, RgwZone, RgwEntities } from '~/app/ceph/rgw/models/rgw-multisite';
@ -99,6 +99,8 @@ export class ServiceFormComponent extends CdForm implements OnInit {
realmList: RgwRealm[] = [];
zonegroupList: RgwZonegroup[] = [];
zoneList: RgwZone[] = [];
filteredZonegroupList: RgwZonegroup[] = [];
filteredZoneList: RgwZone[] = [];
defaultZonegroup: RgwZonegroup;
showRealmCreationForm = false;
defaultsInfo: { defaultRealmName: string; defaultZonegroupName: string; defaultZoneName: string };
@ -118,6 +120,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}));
showMgmtGatewayMessage: boolean = false;
showCertSourceChangeWarning: boolean = false;
showRgwRealmChangedInfo: boolean = false;
rgwModuleEnabled = false;
qatCompressionOptions = [
{ value: QatOptions.hw, label: 'Hardware' },
@ -1115,11 +1118,17 @@ export class ServiceFormComponent extends CdForm implements OnInit {
this.defaultZoneId
);
if (!this.editing) {
this.serviceForm.get('realm_name').setValue(this.defaultsInfo['defaultRealmName']);
this.serviceForm
.get('zonegroup_name')
.setValue(this.defaultsInfo['defaultZonegroupName']);
this.serviceForm.get('zone_name').setValue(this.defaultsInfo['defaultZoneName']);
this.filteredZonegroupList = [...this.zonegroupList];
this.filteredZoneList = [...this.zoneList];
setTimeout(() => {
this.rgwInitializing = true;
this.serviceForm.get('realm_name').setValue(this.defaultsInfo['defaultRealmName']);
this.serviceForm
.get('zonegroup_name')
.setValue(this.defaultsInfo['defaultZonegroupName']);
this.serviceForm.get('zone_name').setValue(this.defaultsInfo['defaultZoneName']);
this.rgwInitializing = false;
});
} else {
if (realm_name && !this.realmNames.includes(realm_name)) {
const realm = new RgwRealm();
@ -1140,9 +1149,28 @@ export class ServiceFormComponent extends CdForm implements OnInit {
zonegroup_name = 'default';
zone_name = 'default';
}
this.serviceForm.get('realm_name').setValue(realm_name);
this.serviceForm.get('zonegroup_name').setValue(zonegroup_name);
this.serviceForm.get('zone_name').setValue(zone_name);
this.originalRgwRealm = realm_name ?? null;
this.originalRgwZonegroup = zonegroup_name ?? null;
this.originalRgwZone = zone_name ?? null;
this.filteredZonegroupList = realm_name
? this.zonegroupList.filter((zg) => {
const realm = this.realmList.find((r) => r.name === realm_name);
return realm ? zg.realm_id === realm.id : true;
})
: [...this.zonegroupList];
const selectedZonegroup = this.zonegroupList.find((zg) => zg.name === zonegroup_name);
this.filteredZoneList = selectedZonegroup?.zones?.length
? this.zoneList.filter((z) =>
selectedZonegroup.zones.some((zgz) => zgz.name === z.name)
)
: [...this.zoneList];
setTimeout(() => {
this.rgwInitializing = true;
this.serviceForm.get('realm_name').setValue(realm_name);
this.serviceForm.get('zonegroup_name').setValue(zonegroup_name);
this.serviceForm.get('zone_name').setValue(zone_name);
this.rgwInitializing = false;
});
}
if (qat) {
this.serviceForm.get(`qat.compression`)?.setValue(qat['compression']);
@ -1153,6 +1181,7 @@ export class ServiceFormComponent extends CdForm implements OnInit {
this.showRealmCreationForm = false;
}
this.updateRgwControlStates();
this.subscribeToRgwSelectionChanges();
},
(_error) => {
const defaultZone = new RgwZone();
@ -1166,6 +1195,87 @@ export class ServiceFormComponent extends CdForm implements OnInit {
);
}
private rgwSelectionSubscribed = false;
private rgwInitializing = false;
private originalRgwRealm: string | null = null;
private originalRgwZonegroup: string | null = null;
private originalRgwZone: string | null = null;
private subscribeToRgwSelectionChanges(): void {
if (this.rgwSelectionSubscribed) {
return;
}
this.rgwSelectionSubscribed = true;
this.serviceForm
.get('realm_name')
.valueChanges.pipe(distinctUntilChanged())
.subscribe((realmName: string) => {
const realm = this.realmList.find((r) => r.name === realmName);
if (realm) {
this.filteredZonegroupList = this.zonegroupList.filter((zg) => zg.realm_id === realm.id);
} else {
this.filteredZonegroupList = [...this.zonegroupList];
}
if (this.rgwInitializing) {
return;
}
this.filteredZoneList = [];
this.updateRgwControlStates();
const firstZonegroup = this.filteredZonegroupList[0]?.name ?? null;
setTimeout(() => {
this.serviceForm.get('zonegroup_name').setValue(firstZonegroup);
this.serviceForm.get('zone_name').setValue(null);
});
});
this.serviceForm
.get('zonegroup_name')
.valueChanges.pipe(
distinctUntilChanged(),
filter((v) => !!v)
)
.subscribe((zonegroupName: string) => {
const zonegroup = this.zonegroupList.find((zg) => zg.name === zonegroupName);
if (zonegroup?.zones?.length) {
this.filteredZoneList = this.zoneList.filter((z) =>
zonegroup.zones.some((zgz) => zgz.name === z.name)
);
} else {
this.filteredZoneList = [...this.zoneList];
}
this.updateRgwControlStates();
// During initial population the caller's setTimeout sets zone_name explicitly.
// For user-driven changes, auto-select the first available zone.
if (!this.rgwInitializing) {
const firstZone = this.filteredZoneList[0]?.name ?? null;
setTimeout(() => {
this.serviceForm.get('zone_name').setValue(firstZone);
});
}
});
// Re-evaluate the banner whenever zone_name settles on a value.
this.serviceForm
.get('zone_name')
.valueChanges.pipe(distinctUntilChanged())
.subscribe(() => {
if (!this.rgwInitializing) {
this.updateRgwRealmChangedInfo();
}
});
}
private updateRgwRealmChangedInfo(): void {
const realm = this.serviceForm.get('realm_name').value;
const zonegroup = this.serviceForm.get('zonegroup_name').value;
const zone = this.serviceForm.get('zone_name').value;
this.showRgwRealmChangedInfo =
realm !== this.originalRgwRealm ||
zonegroup !== this.originalRgwZonegroup ||
zone !== this.originalRgwZone;
}
setNvmeServiceId() {
const group = this.serviceForm.get('group').value;
if (group) {
@ -1220,9 +1330,9 @@ export class ServiceFormComponent extends CdForm implements OnInit {
}
private updateRgwPlacementControlsState(): void {
this.toggleFormControlState('realm_name', this.editing || this.realmList.length === 0);
this.toggleFormControlState('zonegroup_name', this.editing || this.zonegroupList.length === 0);
this.toggleFormControlState('zone_name', this.editing || this.zoneList.length === 0);
this.toggleFormControlState('realm_name', this.realmList.length === 0);
this.toggleFormControlState('zonegroup_name', this.zonegroupList.length === 0);
this.toggleFormControlState('zone_name', this.zoneList.length === 0);
}
private updateGrafanaPasswordControlState(
@ -1277,19 +1387,19 @@ export class ServiceFormComponent extends CdForm implements OnInit {
const zonegroupControl = this.serviceForm.get('zonegroup_name');
const zoneControl = this.serviceForm.get('zone_name');
if (this.editing || this.realmList.length === 0) {
if (this.realmList.length === 0) {
realmControl.disable({ emitEvent: false });
} else {
realmControl.enable({ emitEvent: false });
}
if (this.editing || this.zonegroupList.length === 0) {
if (this.filteredZonegroupList.length === 0) {
zonegroupControl.disable({ emitEvent: false });
} else {
zonegroupControl.enable({ emitEvent: false });
}
if (this.editing || this.zoneList.length === 0) {
if (this.filteredZoneList.length === 0) {
zoneControl.disable({ emitEvent: false });
} else {
zoneControl.enable({ emitEvent: false });