]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add auto-fetch listeners option in subsystem page 69413/head
authorpujashahu <pujashahu@li-3d22414c-327d-11b2-a85c-a49077722390.ibm.com>
Thu, 11 Jun 2026 10:29:51 +0000 (15:59 +0530)
committerpujashahu <pshahu@redhat.com>
Fri, 26 Jun 2026 19:02:30 +0000 (00:32 +0530)
Fixes: https://tracker.ceph.com/issues/77083
Signed-off-by: pujaoshahu <pshahu@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts

index a811c59308879855577f97ac56d98597cb1035ab..aab200d7a1cdb2e973186084cd0b7e7ae59b7d15 100644 (file)
         </cds-text-label>
       </div>
       }
+      <div cdsRow
+           class="form-item">
+        <fieldset class="cds--fieldset">
+          <legend class="cds--label" 
+                  i18n>Listeners</legend>
+          <p class="cds--form__helper-text" i18n>Determine where and how hosts can connect to the subsystem over the network.</p>
+          <cds-radio-group
+            formControlName="listenerMode"
+            orientation="horizontal">
+            <cds-radio
+              [value]="LISTENER_MODE.AUTO_FETCH">
+              <span i18n>Auto-fetch</span>
+            </cds-radio>
+            <cds-radio
+              [value]="LISTENER_MODE.MANUAL">
+              <span i18n>Add manually</span>
+            </cds-radio>
+          </cds-radio-group>
+        </fieldset>
+      </div>
+      @if (listenerMode === LISTENER_MODE.AUTO_FETCH) {
+      <div cdsRow
+           class="form-item">
+        <cds-text-label
+          i18n
+          [invalid]="subnetMaskRef.isInvalid"
+          [invalidText]="subnetMaskInvalidText"
+          helperText="Listeners from this subnet-masks will be use."
+          i18n-helperText>
+          Subnet-mask
+          <input cdsText
+                 cdValidate
+                 #subnetMaskRef="cdValidate"
+                 type="text"
+                 placeholder="e.g. 255.0.0.0"
+                 i18n-placeholder
+                 id="subnetMask"
+                 name="subnetMask"
+                 formControlName="subnetMask"
+                 [invalid]="subnetMaskRef.isInvalid">
+        </cds-text-label>
+        <ng-template #subnetMaskInvalidText>
+          <span i18n>This field is required.</span>
+        </ng-template>
+      </div>
+      }
+      @if (listenerMode === LISTENER_MODE.MANUAL) {
       <div cdsRow
            class="form-item">
         <cds-combo-box i18n
-                       [invalid]="formGroup.get('listeners').invalid && (formGroup.get('listeners').dirty || formGroup.get('listeners').touched)"
+                       cdValidate
+                       #listenersRef="cdValidate"
+                       [invalid]="listenersRef.isInvalid"
                        [invalidText]="listenersInvalidText"
                        [label]="listenersLabel"
                        [helperText]="listenersHelperText"
           </div>
         }
         <ng-template #listenersLabel>
-          <span i18n>Listeners</span>
+          <span i18n>Select listeners</span>
         </ng-template>
         <ng-template #listenersHelperText>
           <span i18n>Select listeners for this subsystem.</span>
           <span i18n>This field is required.</span>
         </ng-template>
       </div>
+      }
     </div>
   </div>
 </form>
index 70db8540580384edff6756ce207848faa2df6ab7..237a76cb3d126013a8ff5c29621bf71b4cad99a8 100644 (file)
@@ -2,6 +2,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ReactiveFormsModule } from '@angular/forms';
 import { RouterTestingModule } from '@angular/router/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
 
 import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
 
@@ -10,7 +11,7 @@ import { SharedModule } from '~/app/shared/shared.module';
 import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1.component';
 import { FormHelper } from '~/testing/unit-test-helper';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { ComboBoxModule, GridModule, InputModule } from 'carbon-components-angular';
+import { ComboBoxModule, GridModule, InputModule, RadioModule } from 'carbon-components-angular';
 
 import { of } from 'rxjs';
 
@@ -35,9 +36,11 @@ describe('NvmeofSubsystemsStepOneComponent', () => {
         NgbTypeaheadModule,
         InputModule,
         GridModule,
-        ComboBoxModule
+        ComboBoxModule,
+        RadioModule
       ],
-      providers: [NgbActiveModal]
+      providers: [NgbActiveModal],
+      schemas: [NO_ERRORS_SCHEMA]
     }).compileComponents();
   });
 
index 866717ef4a56f94f59101abcf321b196b60be824..8894178b9d6d1ea94ad7a405ec77e183edf7ef44 100644 (file)
@@ -33,6 +33,11 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
   };
 
   hosts: ListenerItem[] = [];
+  LISTENER_MODE = {
+    AUTO_FETCH: 'auto-fetch',
+    MANUAL: 'manual'
+  };
+  listenerMode: string = this.LISTENER_MODE.AUTO_FETCH;
 
   constructor(
     public actionLabels: ActionLabelsI18n,
@@ -76,9 +81,18 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
   }
 
   createForm() {
+    const subnetMaskValidators = [
+      CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.AUTO_FETCH }, [Validators.required])
+    ];
+    const listenersValidators = [
+      CdValidators.composeIf({ listenerMode: this.LISTENER_MODE.MANUAL }, [Validators.required])
+    ];
+
     if (this.listenersOnly) {
       this.formGroup = new CdFormGroup({
-        listeners: new UntypedFormControl([])
+        listenerMode: new UntypedFormControl(this.LISTENER_MODE.AUTO_FETCH),
+        subnetMask: new UntypedFormControl('', subnetMaskValidators),
+        listeners: new UntypedFormControl([], listenersValidators)
       });
     } else {
       this.formGroup = new CdFormGroup({
@@ -101,9 +115,15 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
             )
           ]
         }),
-        listeners: new UntypedFormControl([])
+        listenerMode: new UntypedFormControl(this.LISTENER_MODE.AUTO_FETCH),
+        subnetMask: new UntypedFormControl('', subnetMaskValidators),
+        listeners: new UntypedFormControl([], listenersValidators)
       });
     }
+
+    this.formGroup.get('listenerMode').valueChanges.subscribe((mode: string) => {
+      this.listenerMode = mode;
+    });
   }
 
   removeListener(index: number) {
index dc98d870f4dbcb87620bc5e677854acea59c04c0..4f65eaa1f3eb4ab5e1ac1cc00772b5430e34827b 100644 (file)
@@ -92,6 +92,7 @@ describe('NvmeofSubsystemsFormComponent', () => {
       nvmeofService = TestBed.inject(NvmeofService);
       spyOn(nvmeofService, 'createSubsystem').and.returnValue(of({}));
       spyOn(nvmeofService, 'addSubsystemInitiators').and.returnValue(of({}));
+      spyOn(nvmeofService, 'createListeners').and.returnValue(of({}));
     });
 
     it('should be creating request correctly', () => {
@@ -105,6 +106,99 @@ describe('NvmeofSubsystemsFormComponent', () => {
       });
     });
 
+    it('should include network_mask in createSubsystem request when listenerMode is auto-fetch and subnetMask is set', () => {
+      const payload: SubsystemPayload = {
+        nqn: 'test-nqn',
+        gw_group: mockGroupName,
+        addedHosts: [],
+        hostType: HOST_TYPE.ALL,
+        subsystemDchapKey: '',
+        listeners: [],
+        authType: AUTHENTICATION.Unidirectional,
+        hostDchapKeyList: [],
+        listenerMode: 'auto-fetch',
+        subnetMask: '192.168.1.0/24'
+      };
+
+      component.group = mockGroupName;
+      component.onSubmit(payload);
+
+      expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({
+        nqn: 'test-nqn',
+        gw_group: mockGroupName,
+        dhchap_key: '',
+        network_mask: ['192.168.1.0/24']
+      });
+    });
+
+    it('should not include network_mask in createSubsystem request when listenerMode is manual', () => {
+      const payload: SubsystemPayload = {
+        nqn: 'test-nqn',
+        gw_group: mockGroupName,
+        addedHosts: [],
+        hostType: HOST_TYPE.ALL,
+        subsystemDchapKey: '',
+        listeners: [],
+        authType: AUTHENTICATION.Unidirectional,
+        hostDchapKeyList: [],
+        listenerMode: 'manual',
+        subnetMask: '192.168.1.0/24'
+      };
+
+      component.group = mockGroupName;
+      component.onSubmit(payload);
+
+      expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({
+        nqn: 'test-nqn',
+        gw_group: mockGroupName,
+        dhchap_key: ''
+      });
+    });
+
+    it('should call createListeners when listenerMode is manual and listeners are provided', () => {
+      const listeners = [{ content: 'host1', addr: '10.0.0.1' }];
+      const payload: SubsystemPayload = {
+        nqn: 'test-nqn',
+        gw_group: mockGroupName,
+        addedHosts: [],
+        hostType: HOST_TYPE.ALL,
+        subsystemDchapKey: '',
+        listeners,
+        authType: AUTHENTICATION.Unidirectional,
+        hostDchapKeyList: [],
+        listenerMode: 'manual'
+      };
+
+      component.group = mockGroupName;
+      component.onSubmit(payload);
+
+      expect(nvmeofService.createListeners).toHaveBeenCalledWith(
+        'test-nqn.default',
+        mockGroupName,
+        listeners
+      );
+    });
+
+    it('should not call createListeners when listenerMode is auto-fetch', () => {
+      const payload: SubsystemPayload = {
+        nqn: 'test-nqn',
+        gw_group: mockGroupName,
+        addedHosts: [],
+        hostType: HOST_TYPE.ALL,
+        subsystemDchapKey: '',
+        listeners: [{ content: 'host1', addr: '10.0.0.1' }],
+        authType: AUTHENTICATION.Unidirectional,
+        hostDchapKeyList: [],
+        listenerMode: 'auto-fetch',
+        subnetMask: '10.0.0.0/24'
+      };
+
+      component.group = mockGroupName;
+      component.onSubmit(payload);
+
+      expect(nvmeofService.createListeners).not.toHaveBeenCalled();
+    });
+
     it('should add initiators with wildcard when hostType is ALL', () => {
       const payload: SubsystemPayload = {
         nqn: 'test-nqn',
index b3d46425568a8f1792e23a97d1cb70474313d084..88178fb0c8cf15be16e1947ddc6df8f7c08a7a43 100644 (file)
@@ -30,10 +30,26 @@ export type SubsystemPayload = {
   listeners: ListenerItem[];
   authType: AUTHENTICATION.Bidirectional | AUTHENTICATION.Unidirectional;
   hostDchapKeyList: Array<{ dhchap_key: string; host_nqn: string }>;
+  listenerMode?: string;
+  subnetMask?: string;
 };
 
 type StepResult = { step: string; success: boolean; error?: string };
 
+type CreateSubsystemRequest = {
+  nqn: string;
+  gw_group: string;
+  dhchap_key: string;
+  network_mask?: string[];
+};
+
+type SequentialStep = { step: string; call: () => Observable<unknown> };
+
+const LISTENER_MODE = {
+  AUTO_FETCH: 'auto-fetch',
+  MANUAL: 'manual'
+};
+
 const STEP_LABELS = {
   DETAILS: 'Subsystem details',
   HOSTS: 'Host access control',
@@ -159,62 +175,69 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
       hosts: payload.hostType === HOST_TYPE.SPECIFIC ? payload.hostDchapKeyList : [],
       gw_group: this.group
     };
-    this.nvmeofService
-      .createSubsystem({
-        nqn: payload.nqn,
-        gw_group: this.group,
-        dhchap_key: payload.subsystemDchapKey
-      })
-      .subscribe({
-        next: () => {
-          stepResults.push({ step: this.steps[0].label, success: true });
-          const sequentialSteps: { step: string; call: () => Observable<any> }[] = [];
-
-          if (payload.listeners && payload.listeners.length > 0) {
-            sequentialSteps.push({
-              step: $localize`Listeners`,
-              call: () =>
-                this.nvmeofService.createListeners(
-                  `${payload.nqn}.${this.group}`,
-                  this.group,
-                  payload.listeners
-                )
-            });
-          }
 
+    // Prepare subsystem creation request
+    const createSubsystemRequest: CreateSubsystemRequest = {
+      nqn: payload.nqn,
+      gw_group: this.group,
+      dhchap_key: payload.subsystemDchapKey
+    };
+
+    if (payload.listenerMode === LISTENER_MODE.AUTO_FETCH && payload.subnetMask) {
+      createSubsystemRequest.network_mask = [payload.subnetMask];
+    }
+
+    this.nvmeofService.createSubsystem(createSubsystemRequest).subscribe({
+      next: () => {
+        stepResults.push({ step: this.steps[0].label, success: true });
+        const sequentialSteps: SequentialStep[] = [];
+
+        if (
+          payload.listenerMode !== LISTENER_MODE.AUTO_FETCH &&
+          payload.listeners &&
+          payload.listeners.length > 0
+        ) {
           sequentialSteps.push({
-            step: this.steps[1].label,
+            step: $localize`Listeners`,
             call: () =>
-              this.nvmeofService.addSubsystemInitiators(
+              this.nvmeofService.createListeners(
                 `${payload.nqn}.${this.group}`,
-                initiatorRequest
+                this.group,
+                payload.listeners
               )
           });
-
-          this.runSequentialSteps(sequentialSteps, stepResults).subscribe({
-            complete: () => this.showFinalNotification(stepResults)
-          });
-        },
-        error: (err) => {
-          err.preventDefault();
-          const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`;
-          this.notificationService.show(
-            NotificationType.error,
-            $localize`Subsystem creation failed`,
-            errorMsg
-          );
-          this.isSubmitLoading = false;
-          this.router.navigate(['block/nvmeof/subsystems'], {
-            queryParams: { group: this.group }
-          });
         }
-      });
+
+        sequentialSteps.push({
+          step: this.steps[1].label,
+          call: () =>
+            this.nvmeofService.addSubsystemInitiators(
+              `${payload.nqn}.${this.group}`,
+              initiatorRequest
+            )
+        });
+
+        this.runSequentialSteps(sequentialSteps, stepResults).subscribe({
+          complete: () => this.showFinalNotification(stepResults)
+        });
+      },
+      error: (err) => {
+        err.preventDefault();
+        const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`;
+        this.notificationService.show(
+          NotificationType.error,
+          $localize`Subsystem creation failed`,
+          errorMsg
+        );
+        this.isSubmitLoading = false;
+        this.router.navigate(['block/nvmeof/subsystems'], {
+          queryParams: { group: this.group }
+        });
+      }
+    });
   }
 
-  private runSequentialSteps(
-    steps: { step: string; call: () => Observable<any> }[],
-    stepResults: StepResult[]
-  ): Observable<void> {
+  private runSequentialSteps(steps: SequentialStep[], stepResults: StepResult[]): Observable<void> {
     return from(steps).pipe(
       concatMap((step) =>
         step.call().pipe(
index 3b2b57dbf35eea3ee61544297b20c2536b9ba433..77c31a6553d8ad198512a511f93bf39c5244050f 100644 (file)
@@ -192,7 +192,12 @@ export class NvmeofService {
     return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}?gw_group=${group}`);
   }
 
-  createSubsystem(request: { nqn: string; gw_group: string; dhchap_key: string }) {
+  createSubsystem(request: {
+    nqn: string;
+    gw_group: string;
+    dhchap_key: string;
+    network_mask?: string[];
+  }) {
     return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' });
   }
 
index 11d7edab3b0e8b5a4cb33d2077b7faf0838fbf31..7d401ad5d083a0de44bc5a1a7e325cd8ddf2074e 100644 (file)
@@ -169,4 +169,6 @@ export type AuthStepType = {
 export type DetailsStepType = {
   nqn: string;
   listeners: Array<string>;
+  listenerMode?: string;
+  subnetMask?: string;
 };