]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: fix rgw service form realm/zg/zone selection 70344/head
authorAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Mon, 20 Jul 2026 15:51:15 +0000 (21:21 +0530)
committerAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Tue, 21 Jul 2026 05:34:38 +0000 (11:04 +0530)
Fixes: https://tracker.ceph.com/issues/78423
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/services/service-form/service-form.component.ts

index 58613e00cfc4a92a710e7da87b961d3228d2028f..ba3b673cac4e826dd64db79ec03c6214c5a9cc5f 100644 (file)
                 -- No realm available --
               </option>
             }
-            @for (realm of realmList; track realm) {
+            @for (realm of realmList; track realm.name) {
               <option [value]="realm.name">
                 {{ realm.name }}
               </option>
             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>
             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 -->
index 18fe82a0959b341ef8f1486948b8afb868dcb980..a8a5d73ba5567fa5fd2518f2321424df0303fdee 100644 (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);
+      }));
+    });
+  });
 });
index 3371cc199e07a013f7acb87ce4550a3c0b1718c6..403ba026c2b89677ad2f52c10f15efb860c7bf6a 100644 (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 });