From 7dfb4393087b5e28683fa4c8edafe710728e2dfb Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Mon, 20 Jul 2026 21:21:15 +0530 Subject: [PATCH] mgr/dashboard: fix rgw service form realm/zg/zone selection Fixes: https://tracker.ceph.com/issues/78423 Signed-off-by: Aashish Sharma --- .../service-form/service-form.component.html | 17 +- .../service-form.component.spec.ts | 245 +++++++++++++++++- .../service-form/service-form.component.ts | 140 ++++++++-- 3 files changed, 383 insertions(+), 19 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html index 58613e00cfc..ba3b673cac4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html @@ -304,7 +304,7 @@ -- No realm available -- } - @for (realm of realmList; track realm) { + @for (realm of realmList; track realm.name) { @@ -327,7 +327,7 @@ formControlName="zonegroup_name" label="Zonegroup" > - @for (zonegroup of zonegroupList; track zonegroup) { + @for (zonegroup of filteredZonegroupList; track zonegroup.name) { @@ -341,13 +341,24 @@ formControlName="zone_name" label="Zone" > - @for (zone of zoneList; track zone) { + @for (zone of filteredZoneList; track zone.name) { } + + @if (editing && showRgwRealmChangedInfo) { + + The RGW daemons for this service must be restarted to apply the updated realm, + zonegroup, or zone configuration. + + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts index 18fe82a0959..a8a5d73ba55 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts @@ -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); + })); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts index 3371cc199e0..403ba026c2b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts @@ -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 }); -- 2.47.3