]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: create-namespace 67242/head
authorSagar Gopale <sagar.gopale@ibm.com>
Fri, 6 Feb 2026 10:43:31 +0000 (16:13 +0530)
committerSagar Gopale <sagar.gopale@ibm.com>
Tue, 24 Feb 2026 10:50:04 +0000 (16:20 +0530)
Fixes: https://tracker.ceph.com/issues/74826
Signed-off-by: Sagar Gopale <sagar.gopale@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.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 92ed9d76872ccb9cfac7978b24cb1c1e9361295f..8dd4c8e52951bb8a0461cfe1c4829e6c1f90c4a9 100644 (file)
@@ -356,6 +356,11 @@ const routes: Routes = [
           }
         ]
       },
+      {
+        path: `namespaces/${URLVerbs.CREATE}`,
+        component: NvmeofNamespacesFormComponent,
+        data: { breadcrumbs: ActionLabels.CREATE + ' ' + $localize`Namespace` }
+      },
       {
         path: 'subsystems',
         component: NvmeofSubsystemsComponent,
@@ -378,12 +383,12 @@ const routes: Routes = [
           {
             path: `${URLVerbs.CREATE}/:subsystem_nqn/namespace`,
             component: NvmeofNamespacesFormComponent,
-            outlet: 'modal'
+            data: { breadcrumbs: ActionLabels.CREATE + ' ' + $localize`Namespace` }
           },
           {
             path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
             component: NvmeofNamespacesFormComponent,
-            outlet: 'modal'
+            data: { breadcrumbs: ActionLabels.EDIT + ' ' + $localize`Namespace` }
           },
           // initiators
           {
index d1c9b9eeaf4121455c3f2a1076fa8ccbd82dc946..cd8a3718a62f3de8e87f33e9176d8120e8654280 100644 (file)
@@ -1,4 +1,5 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
 
 import { NvmeofGatewayComponent } from './nvmeof-gateway.component';
 
@@ -6,6 +7,7 @@ import { HttpClientModule } from '@angular/common/http';
 import { RouterTestingModule } from '@angular/router/testing';
 import { SharedModule } from '~/app/shared/shared.module';
 import { ComboBoxModule, GridModule, TabsModule } from 'carbon-components-angular';
+import { of } from 'rxjs';
 
 describe('NvmeofGatewayComponent', () => {
   let component: NvmeofGatewayComponent;
@@ -22,7 +24,14 @@ describe('NvmeofGatewayComponent', () => {
         GridModule,
         TabsModule
       ],
-      providers: []
+      providers: [
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            queryParams: of({})
+          }
+        }
+      ]
     }).compileComponents();
 
     fixture = TestBed.createComponent(NvmeofGatewayComponent);
index 6f6b8f8896f4725ce969c53c6ae7f2af8bfd1f1e..fd221fef2fe13ee6b5ccbf17fdb4985c8fc7a0b1 100644 (file)
@@ -3,9 +3,9 @@ import { UntypedFormArray, UntypedFormControl, Validators } from '@angular/forms
 
 import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { Icons } from '~/app/shared/enum/icons.enum';
 import { Permission } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
index bdd3830ffeaeb05e0bb1d581f258e94b73aa16f7..4200fd73622e3467a548830bbfcf25c17676093e 100644 (file)
-<cd-modal [pageURL]="pageURL"
-          [modalRef]="activeModal">
-  <span class="modal-title"
-        i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
-  <ng-container class="modal-content">
-    <form name="nsForm"
-          #formDir="ngForm"
-          [formGroup]="nsForm"
-          novalidate>
-      <div class="modal-body">
-        <!-- Block Pool -->
-        <div class="form-group row">
-          <label class="cd-col-form-label"
-                 for="pool">
-            <span [ngClass]="{'required': !edit}"
-                  i18n>Pool</span>
-          </label>
-          <div class="cd-col-form-input">
-            <input *ngIf="edit"
-                   class="form-control"
-                   type="text"
-                   id="pool-edit"
-                   formControlName="pool">
-            <select *ngIf="!edit"
-                    id="pool-create"
-                    class="form-select"
-                    formControlName="pool">
-              <option *ngIf="rbdPools === null"
-                      [ngValue]="null"
-                      i18n>Loading...</option>
-              <option *ngIf="rbdPools && rbdPools.length === 0"
-                      [ngValue]="null"
-                      i18n>-- No block pools available --</option>
-              <option *ngIf="rbdPools && rbdPools.length > 0"
-                      [ngValue]="null"
-                      i18n>-- Select a pool --</option>
-              <option *ngFor="let pool of rbdPools"
-                      [value]="pool.pool_name">{{ pool.pool_name }}</option>
-            </select>
-            <cd-help-text i18n>
-              An RBD application-enabled pool where the image will be created.
-            </cd-help-text>
-            <span class="invalid-feedback"
-                  *ngIf="nsForm.showError('pool', formDir, 'required')"
-                  i18n>This field is required.</span>
+<form
+  [formGroup]="nsForm"
+  novalidate>
+  <div cdsGrid
+       [useCssGrid]="true"
+       [narrow]="true"
+       [fullWidth]="true">
+
+    <div cdsCol
+         [columnNumbers]="{sm: 4, md: 8}">
+      <div cdsRow
+           class="form-heading form-item">
+        <h3>{{ action | titlecase }} {{ resource | titlecase }}</h3>
+        <cd-help-text [formAllFieldsRequired]="true">
+          <span i18n>
+            Namespaces define the storage volumes that subsystems present to hosts.
+          </span>
+        </cd-help-text>
+      </div>
+
+      <!-- Namespace Count (Create only) -->
+      @if (!edit) {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-number
+            formControlName="nsCount"
+            cdValidate
+            #nsCountRef="cdValidate"
+            label="Number of namespaces"
+            helperText="No. of namespaces to generate. Value must be between 1 and 5."
+            [min]="MIN_NAMESPACE_CREATE"
+            [max]="MAX_NAMESPACE_CREATE"
+            [invalid]="nsCountRef.isInvalid"
+            [invalidText]="nsCountError"
+            i18n-label
+            i18n-helperText>
+          </cds-number>
+          <ng-template #nsCountError>
+            <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('nsCount') }"></ng-container>
+          </ng-template>
+        </div>
+      </div>
+      }
+
+      <!-- Namespace Size (sent as block_size) -->
+      @if (!edit) {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-number
+            formControlName="namespace_size"
+            cdOptionalField="Namespace size (GiB)"
+            label="Namespace size (GiB)"
+            helperText="Specify the size to expose to hosts. Leave blank for full device/file."
+            placeholder="e.g. 100"
+            [min]="0"
+            [invalid]="nsForm.controls['namespace_size'].invalid && (nsForm.controls['namespace_size'].dirty || nsForm.controls['namespace_size'].touched)"
+            invalidText="Value must be greater than or equal to 0."
+            i18n-label
+            i18n-helperText
+            i18n-invalidText>
+          </cds-number>
+        </div>
+      </div>
+      }
+
+      <!-- Host Access (drives no_auto_visible in create request) -->
+      @if (!edit) {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <legend
+            class="cds--type-label-01"
+            i18n>Host access (Initiators)</legend>
+          <div class="form-item">
+            <cds-radio-group
+              formControlName="host_access"
+              orientation="horizontal">
+              <cds-radio
+                value="all"
+                [checked]="true">
+                <div>
+                  <span
+                    class="cds--type-body-compact-01"
+                    i18n>All hosts on the subsystem</span>
+                  <span
+                    class="cds--type-helper-text-01 cds-ml-1 d-block"
+                    i18n>Allow all hosts associated with the selected subsystem to access the namespace.</span>
+                </div>
+              </cds-radio>
+              <cds-radio value="specific">
+                <div>
+                  <span
+                    class="cds--type-body-compact-01"
+                    i18n>Select specific hosts</span>
+                  <span
+                    class="cds--type-helper-text-01 cds-ml-1 d-block"
+                    i18n>Only the selected hosts will be able to access this namespace.</span>
+                </div>
+              </cds-radio>
+            </cds-radio-group>
           </div>
         </div>
-        <!-- Namespace Count -->
-        <div *ngIf="!edit"
-             class="form-group row"
-             id="namespace-count">
-          <label class="cd-col-form-label"
-                 for="nsCount">
-            <span [ngClass]="{'required': !edit}"
-                  i18n>Namespace Count</span>
-          </label>
-          <div class="cd-col-form-input">
-            <cds-number
-              formControlName="nsCount"
-              helperText="The number of namespaces to create"
-              i18n-helperText
-              [min]="MIN_NAMESPACE_CREATE"
-              [max]="MAX_NAMESPACE_CREATE"
-              [invalid]="nsForm.showError('nsCount', formDir, 'max') || nsForm.showError('nsCount', formDir, 'min') || nsForm.showError('nsCount', formDir, 'required')"
-              [invalidText]="nsForm.get('nsCount').hasError('required') ? requiredInvalidText: nsCountInvalidText"
-              size="sm"></cds-number>
+      </div>
+      }
+
+      <!-- Host Selection (Visible only when 'specific' is selected) -->
+      @if (!edit && nsForm.getValue('host_access') === 'specific') {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <div class="form-item">
+            <cds-combo-box
+              type="multi"
+              selectionFeedback="top-after-reopen"
+              label="Select hosts"
+              i18n-label
+              placeholder="Select one or more hosts"
+              [appendInline]="true"
+              [items]="initiatorCandidates"
+              (selected)="onInitiatorSelection($event)"
+              [invalid]="nsForm.controls['initiators'].invalid && (nsForm.controls['initiators'].dirty || nsForm.controls['initiators'].touched)"
+              invalidText="This field is required."
+              i18n-invalidText
+              i18n-placeholder>
+              <cds-dropdown-list></cds-dropdown-list>
+            </cds-combo-box>
           </div>
         </div>
-        <!-- Image Size -->
-        <div class="form-group row">
-          <label class="cd-col-form-label"
-                 for="image_size">
-            <span [ngClass]="{'required': edit}"
-                  i18n>Image Size</span>
-          </label>
-          <div class="cd-col-form-input">
-            <div class="input-group">
-              <input id="size"
-                     class="form-control"
-                     type="text"
-                     formControlName="image_size">
-              <select id="unit"
-                      class="form-input form-select"
-                      formControlName="unit">
-                <option *ngFor="let u of units"
-                        [value]="u"
-                        i18n>{{ u }}</option>
-              </select>
-              <span class="invalid-feedback"
-                    *ngIf="nsForm.showError('image_size', formDir, 'pattern')">
-                <ng-container i18n>Enter a positive integer.</ng-container>
-              </span>
-              <span class="invalid-feedback"
-                    *ngIf="edit && nsForm.showError('image_size', formDir, 'required')">
-                <ng-container i18n>This field is required</ng-container>
-              </span>
-              <span class="invalid-feedback"
-                    id="image-size-invalid"
-                    *ngIf="edit && invalidSizeError">
-                <ng-container i18n>Enter a value above than previous. A block device image can be expanded but not reduced.</ng-container>
-              </span>
-            </div>
+      </div>
+      }
+
+      <!-- Subsystem -->
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          @if (edit) {
+          <cds-text-label
+            label="Subsystem"
+            i18n-label>
+            <input cdsText
+                   readonly
+                   [value]="nsForm.get('subsystem').value" />
+          </cds-text-label>
+          } @else {
+          <cds-select
+            formControlName="subsystem"
+            cdValidate
+            #subsystemRef="cdValidate"
+            label="Select subsystem"
+            [invalid]="subsystemRef.isInvalid"
+            invalidText="This field is required."
+            i18n-label
+            i18n-invalidText>
+            @if (subsystems === undefined) {
+            <option
+              [ngValue]="null"
+              disabled>Loading...</option>
+            }
+            @if (subsystems && subsystems.length === 0) {
+            <option
+              [ngValue]="null"
+              disabled>-- No subsystems available --</option>
+            }
+            @if (subsystems && subsystems.length > 0) {
+            <option
+              value=""
+              selected>Select a subsystem</option>
+            }
+            @for (subsystem of subsystems; track subsystem.nqn) {
+            <option
+              [value]="subsystem.nqn">{{ subsystem.nqn }}</option>
+            }
+          </cds-select>
+          }
+        </div>
+      </div>
+
+      <!-- Pool -->
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          @if (edit) {
+          <cds-text-label
+            label="RBD image pool"
+            i18n-label>
+            <input cdsText
+                   readonly
+                   [value]="nsForm.get('pool').value" />
+          </cds-text-label>
+          } @else {
+          <cds-select
+            formControlName="pool"
+            cdValidate
+            #poolRef="cdValidate"
+            label="RBD image pool"
+            [invalid]="poolRef.isInvalid"
+            invalidText="This field is required."
+            helperText="Pool where the backing Ceph block device resides."
+            i18n-label
+            i18n-invalidText
+            i18n-helperText>
+            @if (rbdPools === null) {
+            <option
+              [ngValue]="null"
+              disabled>Loading...</option>
+            }
+            @if (rbdPools && rbdPools.length === 0) {
+            <option
+              [ngValue]="null"
+              disabled>-- No block pools available --</option>
+            }
+            @if (rbdPools && rbdPools.length > 0) {
+            <option
+              value=""
+              selected>Select a RBD image pool</option>
+            }
+            @for (pool of rbdPools; track pool.pool_name) {
+            <option
+              [value]="pool.pool_name">{{ pool.pool_name }}</option>
+            }
+          </cds-select>
+          }
+        </div>
+      </div>
+
+      <!-- RBD image creation (drives create_image flag in request) -->
+      @if (!edit) {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <legend
+            class="cds--type-label-01"
+            i18n>RBD image creation</legend>
+          <cds-radio-group
+            formControlName="rbd_image_creation"
+            orientation="vertical">
+            <cds-radio
+              value="gateway_provisioned"
+              i18n>Gateway-provisioned image</cds-radio>
+            <cds-radio
+              value="externally_managed"
+              [disabled]="nsForm.getValue('nsCount') > 1"
+              [title]="nsForm.getValue('nsCount') > 1 ? 'Unavailable during bulk creation. RBD images are created automatically.' : ''"
+              i18n>Externally managed image</cds-radio>
+          </cds-radio-group>
+        </div>
+      </div>
+      }
+
+      <!-- Image Name (Visible when 'gateway_provisioned' and nsCount > 1) -->
+      @if (!edit && nsForm.getValue('rbd_image_creation') === 'gateway_provisioned' && nsForm.getValue('nsCount') > 1) {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cd-alert-panel
+            type="info"
+            [dismissible]="false"
+            [showTitle]="false">
+            <strong i18n>For bulk namespace creation, RBD images are provisioned automatically.</strong>
+          </cd-alert-panel>
+
+          <div class="form-item"
+               cdOptionalField="Image name">
+            <label
+              class="cds--type-label-01 cds--label"
+              i18n>Image name</label>
+            <cds-text-label
+              helperText="Provide a name for the images. For bulk creation, this will be used as the base prefix with numeric suffixes (e.g., img-1, img-2). Leave blank to auto-generate."
+              [invalid]="rbdImageNameRef.isInvalid"
+              [invalidText]="rbdImageNameError"
+              i18n-helperText>
+              <input cdsText
+                     placeholder="Enter a name"
+                     formControlName="rbd_image_name"
+                     cdValidate
+                     #rbdImageNameRef="cdValidate"
+                     [invalid]="rbdImageNameRef.isInvalid" />
+            </cds-text-label>
+            <ng-template #rbdImageNameError>
+              <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('rbd_image_name') }"></ng-container>
+            </ng-template>
           </div>
         </div>
       </div>
-      <div class="modal-footer">
-        <div class="text-right">
-          <cd-form-button-panel (submitActionEvent)="onSubmit()"
-                                [form]="nsForm"
-                                [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+      }
+
+      <!-- Image Selection (Visible only when 'externally_managed' is selected) -->
+      @if (!edit && nsForm.getValue('rbd_image_creation') === 'externally_managed') {
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-select
+            formControlName="rbd_image_name"
+            cdValidate
+            #rbdImageSelectRef="cdValidate"
+            label="RBD Image"
+            [invalid]="rbdImageSelectRef.isInvalid"
+            invalidText="This field is required."
+            helperText="Select an existing RBD image from the pool to expose as a namespace."
+            i18n-label
+            i18n-invalidText
+            i18n-helperText>
+            <option
+              [ngValue]="null"
+              disabled
+              selected>Select an image</option>
+            @for (img of rbdImages; track img.name) {
+            <option
+              [value]="img.name">{{ img.name }} ({{ img.size | dimlessBinary }})</option>
+            }
+          </cds-select>
         </div>
       </div>
-    </form>
-  </ng-container>
-</cd-modal>
+      }
+
+      <!-- Image Size -->
+      @if (!edit && nsForm.getValue('rbd_image_creation') !== 'externally_managed') {
+      <div cdsRow
+           class="form-item">
+        <div
+          cdsCol
+          [columnNumbers]="{md: 4}">
+          <cds-text-label
+            helperText="The size of the namespace image."
+            [invalid]="imageSizeRef.isInvalid"
+            [invalidText]="sizeError"
+            i18n-helperText>
+            Image Size
+            <input cdsText
+                   type="text"
+                   placeholder="e.g. 100 GiB"
+                   id="image_size"
+                   formControlName="image_size"
+                   cdValidate
+                   #imageSizeRef="cdValidate"
+                   [invalid]="imageSizeRef.isInvalid"
+                   defaultUnit="GiB"
+                   [min]="0"
+                   cdDimlessBinary>
+          </cds-text-label>
+          <ng-template #sizeError>
+            <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('image_size') }"></ng-container>
+          </ng-template>
+        </div>
+      </div>
+      }
+
+      <div cdsRow>
+        <cd-form-button-panel
+          (submitActionEvent)="onSubmit()"
+          [form]="nsForm"
+          [submitText]="(action | titlecase) + ' ' + (resource | titlecase)"
+          wrappingClass="text-right form-button">
+        </cd-form-button-panel>
+      </div>
+
+    </div>
+  </div>
+</form>
+
+<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>
index 28612801c55e2b5d040bf5158fb4d1315226fe3b..7ad2aec4e64ad8676e1ed95c010a86a97886db1f 100644 (file)
@@ -1,4 +1,5 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { HttpResponse } from '@angular/common/http';
 import { ReactiveFormsModule } from '@angular/forms';
 import { RouterTestingModule } from '@angular/router/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
@@ -13,12 +14,14 @@ import { SharedModule } from '~/app/shared/shared.module';
 import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form.component';
 import { FormHelper, Mocks } from '~/testing/unit-test-helper';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { of } from 'rxjs';
+import { of, Observable } from 'rxjs';
 import { PoolService } from '~/app/shared/api/pool.service';
-import { NumberModule } from 'carbon-components-angular';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NumberModule, RadioModule, ComboBoxModule, SelectModule } from 'carbon-components-angular';
 import { ActivatedRoute, Router } from '@angular/router';
 import { By } from '@angular/platform-browser';
 import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { NvmeofInitiatorCandidate } from '~/app/shared/models/nvmeof';
 
 const MOCK_POOLS = [
   Mocks.getPool('pool-1', 1, ['cephfs']),
@@ -31,6 +34,12 @@ class MockPoolsService {
   }
 }
 
+class MockTaskWrapperService {
+  wrapTaskAroundCall(args: { task: any; call: Observable<any> }) {
+    return args.call;
+  }
+}
+
 const MOCK_NS_RESPONSE = {
   nsid: 1,
   uuid: '185d541f-76bf-45b5-b445-f71829346c38',
@@ -76,7 +85,8 @@ describe('NvmeofNamespacesFormComponent', () => {
       providers: [
         NgbActiveModal,
         { provide: PoolService, useClass: MockPoolsService },
-        { provide: ActivatedRoute, useValue: activatedRouteStub }
+        { provide: ActivatedRoute, useValue: activatedRouteStub },
+        { provide: TaskWrapperService, useClass: MockTaskWrapperService }
       ],
       imports: [
         HttpClientTestingModule,
@@ -85,6 +95,9 @@ describe('NvmeofNamespacesFormComponent', () => {
         RouterTestingModule,
         SharedModule,
         NumberModule,
+        RadioModule,
+        ComboBoxModule,
+        SelectModule,
         ToastrModule.forRoot()
       ]
     }).compileComponents();
@@ -99,7 +112,13 @@ describe('NvmeofNamespacesFormComponent', () => {
     beforeEach(() => {
       router = TestBed.inject(Router);
       nvmeofService = TestBed.inject(NvmeofService);
-      spyOn(nvmeofService, 'createNamespace').and.stub();
+      spyOn(nvmeofService, 'createNamespace').and.returnValue(
+        of(new HttpResponse({ body: MOCK_NS_RESPONSE }))
+      );
+      spyOn(nvmeofService, 'addNamespaceInitiators').and.returnValue(of({}));
+      spyOn(nvmeofService, 'getInitiators').and.returnValue(
+        of([{ nqn: 'host1' }, { nqn: 'host2' }])
+      );
       spyOn(component, 'randomString').and.returnValue(MOCK_RANDOM_STRING);
       Object.defineProperty(router, 'url', {
         get: jasmine.createSpy('url').and.returnValue(MOCK_ROUTER.createUrl)
@@ -107,14 +126,12 @@ describe('NvmeofNamespacesFormComponent', () => {
       component.ngOnInit();
       form = component.nsForm;
       formHelper = new FormHelper(form);
-    });
-    it('should have set create fields correctly', () => {
-      expect(component.rbdPools.length).toBe(2);
-      fixture.detectChanges();
-      const poolEl = fixture.debugElement.query(By.css('#pool-create')).nativeElement;
-      expect(poolEl.value).toBe('rbd');
+      formHelper.setValue('pool', 'rbd');
     });
     it('should create 5 namespaces correctly', () => {
+      formHelper.setValue('pool', 'rbd');
+      formHelper.setValue('image_size', 1073741824);
+      formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
       component.onSubmit();
       expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5);
       expect(nvmeofService.createNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, {
@@ -122,18 +139,73 @@ describe('NvmeofNamespacesFormComponent', () => {
         rbd_image_name: `nvme_rbd_default_${MOCK_RANDOM_STRING}`,
         rbd_pool: 'rbd',
         create_image: true,
-        rbd_image_size: 1073741824
+        rbd_image_size: 1073741824,
+        no_auto_visible: false
       });
     });
+
+    it('should create multiple namespaces with suffixed custom image names', () => {
+      formHelper.setValue('pool', 'rbd');
+      formHelper.setValue('image_size', 1073741824);
+      formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
+      formHelper.setValue('nsCount', 2);
+      formHelper.setValue('rbd_image_name', 'test-img');
+      component.onSubmit();
+      expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(2);
+      expect((nvmeofService.createNamespace as any).calls.argsFor(0)[1].rbd_image_name).toBe(
+        'test-img-1'
+      );
+      expect((nvmeofService.createNamespace as any).calls.argsFor(1)[1].rbd_image_name).toBe(
+        'test-img-2'
+      );
+    });
     it('should give error on invalid image size', () => {
       formHelper.setValue('image_size', -56);
       component.onSubmit();
-      formHelper.expectError('image_size', 'pattern');
+      // Expect form error instead of control error as validation happens on submit
+      expect(component.nsForm.hasError('cdSubmitButton')).toBeTruthy();
     });
     it('should give error on 0 image size', () => {
       formHelper.setValue('image_size', 0);
       component.onSubmit();
-      formHelper.expectError('image_size', 'min');
+      // Since validation is custom/in-template, we might verify expected behavior differently
+      // checking if submit failed via checking spy calls
+      expect(nvmeofService.createNamespace).not.toHaveBeenCalled();
+      expect(component.nsForm.hasError('cdSubmitButton')).toBeTruthy();
+    });
+
+    it('should require initiators when host access is specific', () => {
+      formHelper.setValue('host_access', 'specific');
+      formHelper.expectError('initiators', 'required');
+      formHelper.setValue('initiators', ['host1']);
+      formHelper.expectValid('initiators');
+    });
+
+    it('should call addNamespaceInitiators on submit with specific hosts', () => {
+      formHelper.setValue('pool', 'rbd');
+      formHelper.setValue('image_size', 1073741824);
+      formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
+      formHelper.setValue('host_access', 'specific');
+      formHelper.setValue('initiators', ['host1']);
+      component.onSubmit();
+      expect(nvmeofService.createNamespace).toHaveBeenCalled();
+      // Wait for async operations if needed, or check if mocking is correct
+      expect(nvmeofService.addNamespaceInitiators).toHaveBeenCalledTimes(5); // 5 namespaces created by default
+      expect(nvmeofService.addNamespaceInitiators).toHaveBeenCalledWith(1, {
+        gw_group: MOCK_GROUP,
+        subsystem_nqn: MOCK_SUBSYSTEM,
+        host_nqn: 'host1'
+      });
+    });
+
+    it('should update initiators form control on selection', () => {
+      const mockEvent: NvmeofInitiatorCandidate[] = [
+        { content: 'host1', selected: true },
+        { content: 'host2', selected: true }
+      ];
+      component.onInitiatorSelection(mockEvent);
+      expect(component.nsForm.get('initiators').value).toEqual(['host1', 'host2']);
+      expect(component.nsForm.get('initiators').dirty).toBe(true);
     });
   });
   describe('should test edit form', () => {
@@ -141,49 +213,46 @@ describe('NvmeofNamespacesFormComponent', () => {
       router = TestBed.inject(Router);
       nvmeofService = TestBed.inject(NvmeofService);
       spyOn(nvmeofService, 'getNamespace').and.returnValue(of(MOCK_NS_RESPONSE));
-      spyOn(nvmeofService, 'updateNamespace').and.stub();
+      spyOn(nvmeofService, 'updateNamespace').and.returnValue(
+        of(new HttpResponse({ status: 200 }))
+      );
       Object.defineProperty(router, 'url', {
         get: jasmine.createSpy('url').and.returnValue(MOCK_ROUTER.editUrl)
       });
       fixture.detectChanges();
     });
+
     it('should have set edit fields correctly', () => {
       expect(nvmeofService.getNamespace).toHaveBeenCalledTimes(1);
-      const poolEl = fixture.debugElement.query(By.css('#pool-edit')).nativeElement;
-      expect(poolEl.disabled).toBeTruthy();
-      expect(poolEl.value).toBe(MOCK_NS_RESPONSE['rbd_pool_name']);
-      const sizeEl = fixture.debugElement.query(By.css('#size')).nativeElement;
-      expect(sizeEl.value).toBe('1');
-      const unitEl = fixture.debugElement.query(By.css('#unit')).nativeElement;
-      expect(unitEl.value).toBe('GiB');
+      expect(component.nsForm.get('pool').disabled).toBeTruthy();
+      expect(component.nsForm.get('pool').value).toBe(MOCK_NS_RESPONSE['rbd_pool_name']);
+      // Size formatted by pipe
+      expect(component.nsForm.get('image_size').value).toBe('1 GiB');
     });
-    it('should not show namesapce count ', () => {
-      const nsCountEl = fixture.debugElement.query(By.css('#namespace-count'));
+
+    it('should not show namespace count', () => {
+      const nsCountEl = fixture.debugElement.query(By.css('cds-number[formControlName="nsCount"]'));
       expect(nsCountEl).toBeFalsy();
     });
+
     it('should give error with no change in image size', () => {
-      component.onSubmit();
-      expect(component.invalidSizeError).toBe(true);
-      fixture.detectChanges();
-      const imageSizeInvalidEL = fixture.debugElement.query(By.css('#image-size-invalid'));
-      expect(imageSizeInvalidEL).toBeTruthy();
+      component.nsForm.get('image_size').updateValueAndValidity();
+      expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true);
     });
+
     it('should give error when size less than previous (1 GB) provided', () => {
       form = component.nsForm;
       formHelper = new FormHelper(form);
-      formHelper.setValue('unit', 'MiB');
-      component.onSubmit();
-      expect(component.invalidSizeError).toBe(true);
-      fixture.detectChanges();
-      const imageSizeInvalidEL = fixture.debugElement.query(By.css('#image-size-invalid'))
-        .nativeElement;
-      expect(imageSizeInvalidEL).toBeTruthy();
+      formHelper.setValue('image_size', '512 MiB'); // Less than 1 GiB
+      component.nsForm.get('image_size').updateValueAndValidity();
+      expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true);
     });
+
     it('should have edited namespace successfully', () => {
       component.ngOnInit();
       form = component.nsForm;
       formHelper = new FormHelper(form);
-      formHelper.setValue('image_size', 2);
+      formHelper.setValue('image_size', '2 GiB');
       component.onSubmit();
       expect(nvmeofService.updateNamespace).toHaveBeenCalledTimes(1);
       expect(nvmeofService.updateNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, MOCK_NSID, {
index 8c837d3885dd1b0c5698fa1702896a95a8eed69d..1a6e47a9a8365e90454c9807228a705cd22c62e7 100644 (file)
@@ -1,24 +1,35 @@
-import { Component, OnInit } from '@angular/core';
+import { Component, OnDestroy, OnInit } from '@angular/core';
 import { UntypedFormControl, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 import {
   NamespaceCreateRequest,
+  NamespaceInitiatorRequest,
   NamespaceUpdateRequest,
   NvmeofService
 } from '~/app/shared/api/nvmeof.service';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { FinishedTask } from '~/app/shared/models/finished-task';
-import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
+import {
+  NvmeofSubsystem,
+  NvmeofSubsystemInitiator,
+  NvmeofSubsystemNamespace,
+  NvmeofNamespaceListResponse,
+  NvmeofInitiatorCandidate,
+  NsFormField,
+  RbdImageCreation,
+  HOST_TYPE
+} from '~/app/shared/models/nvmeof';
 import { Permission } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { Pool } from '../../pool/pool';
 import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdPool, RbdImage } from '~/app/shared/api/rbd.model';
 import { RbdService } from '~/app/shared/api/rbd.service';
 import { FormatterService } from '~/app/shared/services/formatter.service';
-import { forkJoin, Observable } from 'rxjs';
+import { forkJoin, Observable, of, Subject } from 'rxjs';
+import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 import { HttpResponse } from '@angular/common/http';
@@ -29,7 +40,7 @@ import { HttpResponse } from '@angular/common/http';
   styleUrls: ['./nvmeof-namespaces-form.component.scss'],
   standalone: false
 })
-export class NvmeofNamespacesFormComponent implements OnInit {
+export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
   action: string;
   permission: Permission;
   poolPermission: Permission;
@@ -38,16 +49,30 @@ export class NvmeofNamespacesFormComponent implements OnInit {
   edit: boolean = false;
   nsForm: CdFormGroup;
   subsystemNQN: string;
-  rbdPools: Array<Pool> = null;
-  units: Array<string> = ['MiB', 'GiB', 'TiB'];
+  subsystems?: NvmeofSubsystem[];
+  rbdPools: Pool[] | null = null;
+  rbdImages: RbdImage[] = [];
+  initiatorCandidates: NvmeofInitiatorCandidate[] = [];
+
+  // Stores all RBD images fetched for the selected pool
+  private allRbdImages: RbdImage[] = [];
+  // Maps pool name to a Set of used image names for O(1) lookup
+  private usedRbdImages: Map<string, Set<string>> = new Map();
+  private lastSubsystemNqn: string;
+
   nsid: string;
-  currentBytes: number;
-  invalidSizeError: boolean;
+  currentBytes: number = 0;
   group: string;
   MAX_NAMESPACE_CREATE: number = 5;
   MIN_NAMESPACE_CREATE: number = 1;
-  requiredInvalidText: string = $localize`This field is required`;
-  nsCountInvalidText: string = $localize`The namespace count should be between 1 and 5`;
+  private destroy$ = new Subject<void>();
+  INVALID_TEXTS: Record<string, string> = {
+    required: $localize`This field is required.`,
+    min: $localize`The namespace count should be between 1 and 5.`,
+    max: $localize`The namespace count should be between 1 and 5.`,
+    minSize: $localize`Enter a value larger than previous. A block device image can be expanded but not reduced.`,
+    rbdImageName: $localize`Image name contains invalid characters.`
+  };
 
   constructor(
     public actionLabels: ActionLabelsI18n,
@@ -58,20 +83,28 @@ export class NvmeofNamespacesFormComponent implements OnInit {
     private rbdService: RbdService,
     private router: Router,
     private route: ActivatedRoute,
-    public activeModal: NgbActiveModal,
     public formatterService: FormatterService,
     public dimlessBinaryPipe: DimlessBinaryPipe
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
     this.poolPermission = this.authStorageService.getPermissions().pool;
     this.resource = $localize`Namespace`;
-    this.pageURL = 'block/nvmeof/subsystems';
+    this.pageURL = 'block/nvmeof/gateways';
+  }
+
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
   }
 
   init() {
     this.route.queryParams.subscribe((params) => {
       this.group = params?.['group'];
+      if (params?.['subsystem_nqn']) {
+        this.subsystemNQN = params?.['subsystem_nqn'];
+      }
     });
+
     this.createForm();
     this.action = this.actionLabels.CREATE;
     this.route.params.subscribe((params: { subsystem_nqn: string; nsid: string }) => {
@@ -86,24 +119,41 @@ export class NvmeofNamespacesFormComponent implements OnInit {
     this.nvmeofService
       .getNamespace(this.subsystemNQN, this.nsid, this.group)
       .subscribe((res: NvmeofSubsystemNamespace) => {
-        const convertedSize = this.dimlessBinaryPipe.transform(res.rbd_image_size).split(' ');
         this.currentBytes =
           typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
-        this.nsForm.get('pool').setValue(res.rbd_pool_name);
-        this.nsForm.get('unit').setValue(convertedSize[1]);
-        this.nsForm.get('image_size').setValue(convertedSize[0]);
-        this.nsForm.get('image_size').addValidators(Validators.required);
-        this.nsForm.get('pool').disable();
+        this.nsForm.get(NsFormField.POOL).setValue(res.rbd_pool_name);
+        this.nsForm
+          .get(NsFormField.IMAGE_SIZE)
+          .setValue(this.dimlessBinaryPipe.transform(res.rbd_image_size));
+        this.nsForm.get(NsFormField.IMAGE_SIZE).addValidators(Validators.required);
+        this.nsForm.get(NsFormField.POOL).disable();
+        this.nsForm.get(NsFormField.SUBSYSTEM).disable();
+        this.nsForm.get(NsFormField.SUBSYSTEM).setValue(this.subsystemNQN);
       });
   }
 
   initForCreate() {
     this.poolService.getList().subscribe((resp: Pool[]) => {
       this.rbdPools = resp.filter(this.rbdService.isRBDPool);
-      if (this.rbdPools?.length) {
-        this.nsForm.get('pool').setValue(this.rbdPools[0].pool_name);
-      }
     });
+    this.route.queryParams
+      .pipe(
+        filter((params) => params?.['group']),
+        tap((params) => {
+          this.group = params['group'];
+          this.fetchUsedImages();
+        }),
+        switchMap(() => this.nvmeofService.listSubsystems(this.group))
+      )
+      .subscribe((subsystems: NvmeofSubsystem[]) => {
+        this.subsystems = subsystems;
+        if (this.subsystemNQN) {
+          const selectedSubsystem = this.subsystems.find((s) => s.nqn === this.subsystemNQN);
+          if (selectedSubsystem) {
+            this.nsForm.get(NsFormField.SUBSYSTEM).setValue(selectedSubsystem.nqn);
+          }
+        }
+      });
   }
 
   ngOnInit() {
@@ -113,21 +163,195 @@ export class NvmeofNamespacesFormComponent implements OnInit {
     } else {
       this.initForCreate();
     }
+    const subsystemControl = this.nsForm.get(NsFormField.SUBSYSTEM);
+    if (subsystemControl) {
+      subsystemControl.valueChanges.subscribe((nqn: string) => {
+        this.onSubsystemChange(nqn);
+      });
+    }
+  }
+
+  onPoolChange(): void {
+    const pool = this.nsForm.getValue(NsFormField.POOL);
+    if (!pool) return;
+
+    this.rbdService
+      .list({ pool_name: pool, offset: '0', limit: '-1' })
+      .subscribe((pools: RbdPool[]) => {
+        const selectedPool = pools.find((p) => p.pool_name === pool);
+        this.allRbdImages = selectedPool?.value ?? [];
+        this.filterImages();
+
+        const imageControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
+        const currentImage = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
+        if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) {
+          imageControl.setValue(null);
+        }
+        imageControl.markAsUntouched();
+        imageControl.markAsPristine();
+      });
+  }
+
+  fetchUsedImages(): void {
+    if (!this.group) return;
+
+    this.nvmeofService
+      .listNamespaces(this.group)
+      .subscribe((response: NvmeofNamespaceListResponse) => {
+        const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response)
+          ? response
+          : response?.namespaces ?? [];
+        this.usedRbdImages = namespaces.reduce((map, ns) => {
+          if (!map.has(ns.rbd_pool_name)) {
+            map.set(ns.rbd_pool_name, new Set<string>());
+          }
+          map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name);
+          return map;
+        }, new Map<string, Set<string>>());
+        this.filterImages();
+      });
+  }
+
+  onSubsystemChange(nqn: string): void {
+    if (!nqn || nqn === this.lastSubsystemNqn) return;
+    this.lastSubsystemNqn = nqn;
+    this.nvmeofService
+      .getInitiators(nqn, this.group)
+      .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
+        const initiators = Array.isArray(response) ? response : response?.hosts || [];
+        this.initiatorCandidates = initiators.map((initiator) => ({
+          content: initiator.nqn,
+          selected: false
+        }));
+      });
+  }
+
+  onInitiatorSelection(event: NvmeofInitiatorCandidate[]) {
+    // Carbon ComboBox (selected) emits the full array of selected items
+    const selectedInitiators = Array.isArray(event) ? event.map((e) => e.content) : [];
+    this.nsForm
+      .get(NsFormField.INITIATORS)
+      .setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
+    this.nsForm.get(NsFormField.INITIATORS).markAsDirty();
+    this.nsForm.get(NsFormField.INITIATORS).markAsTouched();
+  }
+
+  private filterImages(): void {
+    const pool = this.nsForm.getValue(NsFormField.POOL);
+    if (!pool) {
+      this.rbdImages = [];
+      return;
+    }
+    const usedInPool = this.usedRbdImages.get(pool);
+    this.rbdImages = usedInPool
+      ? this.allRbdImages.filter((img) => !usedInPool.has(img.name))
+      : [...this.allRbdImages];
   }
 
   createForm() {
     this.nsForm = new CdFormGroup({
-      pool: new UntypedFormControl(null, {
+      [NsFormField.POOL]: new UntypedFormControl('', {
         validators: [Validators.required]
       }),
-      image_size: new UntypedFormControl(1, [CdValidators.number(false), Validators.min(1)]),
-      unit: new UntypedFormControl(this.units[1]),
-      nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
+      [NsFormField.SUBSYSTEM]: new UntypedFormControl('', {
+        validators: [Validators.required]
+      }),
+      [NsFormField.IMAGE_SIZE]: new UntypedFormControl(null, {
+        validators: [
+          Validators.required,
+          CdValidators.custom('minSize', (value: any) => {
+            if (value !== null && value !== undefined && value !== '') {
+              const bytes = this.formatterService.toBytes(value);
+              if (
+                (!this.edit && bytes <= 0) ||
+                (this.edit && this.currentBytes && bytes <= this.currentBytes)
+              ) {
+                return { minSize: true };
+              }
+            }
+            return null;
+          })
+        ],
+        updateOn: 'blur'
+      }),
+      [NsFormField.NS_COUNT]: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
         Validators.required,
         Validators.max(this.MAX_NAMESPACE_CREATE),
         Validators.min(this.MIN_NAMESPACE_CREATE)
-      ])
+      ]),
+      [NsFormField.RBD_IMAGE_CREATION]: new UntypedFormControl(
+        RbdImageCreation.GATEWAY_PROVISIONED
+      ),
+
+      [NsFormField.RBD_IMAGE_NAME]: new UntypedFormControl(null, [
+        CdValidators.custom('rbdImageName', (value: any) => {
+          if (!value) return null;
+          return /^[^@/]+$/.test(value) ? null : { rbdImageName: true };
+        })
+      ]),
+      [NsFormField.NAMESPACE_SIZE]: new UntypedFormControl(null, [Validators.min(0)]), // sent as block_size in create request
+      [NsFormField.HOST_ACCESS]: new UntypedFormControl(HOST_TYPE.ALL), // drives no_auto_visible in create request
+      [NsFormField.INITIATORS]: new UntypedFormControl([]) // sent via addNamespaceInitiators API
     });
+
+    this.nsForm
+      .get(NsFormField.POOL)
+      .valueChanges.pipe(takeUntil(this.destroy$))
+      .subscribe(() => {
+        this.onPoolChange();
+      });
+
+    this.nsForm
+      .get(NsFormField.NS_COUNT)
+      .valueChanges.pipe(takeUntil(this.destroy$))
+      .subscribe((count: number) => {
+        if (count > 1) {
+          const creationControl = this.nsForm.get(NsFormField.RBD_IMAGE_CREATION);
+          if (creationControl.value === RbdImageCreation.EXTERNALLY_MANAGED) {
+            creationControl.setValue(RbdImageCreation.GATEWAY_PROVISIONED);
+          }
+        }
+      });
+
+    this.nsForm
+      .get(NsFormField.RBD_IMAGE_CREATION)
+      .valueChanges.pipe(takeUntil(this.destroy$))
+      .subscribe((mode: string) => {
+        const nameControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME);
+        const countControl = this.nsForm.get(NsFormField.NS_COUNT);
+        const imageSizeControl = this.nsForm.get(NsFormField.IMAGE_SIZE);
+
+        if (mode === RbdImageCreation.EXTERNALLY_MANAGED) {
+          countControl.setValue(1);
+          countControl.disable();
+          this.onPoolChange();
+          nameControl.addValidators(Validators.required);
+          imageSizeControl.disable();
+          imageSizeControl.removeValidators(Validators.required);
+        } else {
+          countControl.enable();
+          nameControl.removeValidators(Validators.required);
+          imageSizeControl.enable();
+          imageSizeControl.addValidators(Validators.required);
+        }
+        nameControl.updateValueAndValidity();
+        imageSizeControl.updateValueAndValidity();
+      });
+
+    this.nsForm
+      .get(NsFormField.HOST_ACCESS)
+      .valueChanges.pipe(takeUntil(this.destroy$))
+      .subscribe((mode: string) => {
+        const initiatorsControl = this.nsForm.get(NsFormField.INITIATORS);
+        if (mode === HOST_TYPE.SPECIFIC) {
+          initiatorsControl.addValidators(Validators.required);
+        } else {
+          initiatorsControl.removeValidators(Validators.required);
+          initiatorsControl.setValue([]);
+          this.initiatorCandidates.forEach((i) => (i.selected = false));
+        }
+        initiatorsControl.updateValueAndValidity();
+      });
   }
 
   buildUpdateRequest(rbdImageSize: number): Observable<HttpResponse<Object>> {
@@ -146,80 +370,131 @@ export class NvmeofNamespacesFormComponent implements OnInit {
     return Math.random().toString(36).substring(2);
   }
 
-  buildCreateRequest(rbdImageSize: number, nsCount: number): Observable<HttpResponse<Object>>[] {
-    const pool = this.nsForm.getValue('pool');
+  buildCreateRequest(
+    rbdImageSize: number,
+    nsCount: number,
+    noAutoVisible: boolean
+  ): Observable<HttpResponse<Object>>[] {
+    const pool = this.nsForm.getValue(NsFormField.POOL);
     const requests: Observable<HttpResponse<Object>>[] = [];
+    const creationMode = this.nsForm.getValue(NsFormField.RBD_IMAGE_CREATION);
+    const isGatewayProvisioned = creationMode === RbdImageCreation.GATEWAY_PROVISIONED;
+
+    const loopCount = isGatewayProvisioned ? nsCount : 1;
 
-    for (let i = 1; i <= nsCount; i++) {
+    for (let i = 1; i <= loopCount; i++) {
       const request: NamespaceCreateRequest = {
         gw_group: this.group,
-        rbd_image_name: `nvme_${pool}_${this.group}_${this.randomString()}`,
         rbd_pool: pool,
-        create_image: true
+        create_image: isGatewayProvisioned,
+        no_auto_visible: noAutoVisible
       };
-      if (rbdImageSize) {
-        request['rbd_image_size'] = rbdImageSize;
+
+      const blockSize = this.nsForm.getValue(NsFormField.NAMESPACE_SIZE);
+      if (blockSize) {
+        request.block_size = blockSize;
       }
-      requests.push(this.nvmeofService.createNamespace(this.subsystemNQN, request));
-    }
 
-    return requests;
-  }
+      if (isGatewayProvisioned) {
+        request.rbd_image_name = `nvme_${pool}_${this.group}_${this.randomString()}`;
+        if (rbdImageSize) {
+          request['rbd_image_size'] = rbdImageSize;
+        }
+      }
+
+      const rbdImageName = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
+      if (rbdImageName) {
+        request['rbd_image_name'] = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName;
+      }
 
-  validateSize() {
-    const unit = this.nsForm.getValue('unit');
-    const image_size = this.nsForm.getValue('image_size');
-    if (image_size && unit) {
-      const bytes = this.formatterService.toBytes(image_size + unit);
-      return bytes <= this.currentBytes;
+      const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM) || this.subsystemNQN;
+      requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
     }
-    return null;
+
+    return requests;
   }
 
   onSubmit() {
-    if (this.validateSize()) {
-      this.invalidSizeError = true;
+    if (this.nsForm.invalid) {
       this.nsForm.setErrors({ cdSubmitButton: true });
+      this.nsForm.markAllAsTouched();
+      return;
+    }
+
+    const component = this;
+    const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
+    const image_size = this.nsForm.getValue(NsFormField.IMAGE_SIZE);
+    const nsCount = this.nsForm.getValue(NsFormField.NS_COUNT);
+    const hostAccess = this.nsForm.getValue(NsFormField.HOST_ACCESS);
+    const selectedHosts: string[] = this.nsForm.getValue(NsFormField.INITIATORS) || [];
+    const noAutoVisible = hostAccess === HOST_TYPE.SPECIFIC;
+    let action: Observable<any>;
+    let rbdImageSize: number = null;
+
+    if (image_size) {
+      rbdImageSize = this.formatterService.toBytes(image_size);
+    }
+
+    if (this.edit) {
+      action = this.taskWrapperService.wrapTaskAroundCall({
+        task: new FinishedTask(taskUrl, {
+          nqn: this.subsystemNQN,
+          nsid: this.nsid
+        }),
+        call: this.buildUpdateRequest(rbdImageSize)
+      });
     } else {
-      this.invalidSizeError = false;
-      const component = this;
-      const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
-      const image_size = this.nsForm.getValue('image_size');
-      const nsCount = this.nsForm.getValue('nsCount');
-      let action: Observable<HttpResponse<Object>>;
-      let rbdImageSize: number = null;
-
-      if (image_size) {
-        const image_size_unit = this.nsForm.getValue('unit');
-        const value: number = this.formatterService.toBytes(image_size + image_size_unit);
-        rbdImageSize = value;
-      }
-      if (this.edit) {
-        action = this.taskWrapperService.wrapTaskAroundCall({
-          task: new FinishedTask(taskUrl, {
-            nqn: this.subsystemNQN,
-            nsid: this.nsid
-          }),
-          call: this.buildUpdateRequest(rbdImageSize)
-        });
-      } else {
-        action = this.taskWrapperService.wrapTaskAroundCall({
-          task: new FinishedTask(taskUrl, {
-            nqn: this.subsystemNQN,
-            nsCount
-          }),
-          call: forkJoin(this.buildCreateRequest(rbdImageSize, nsCount))
-        });
-      }
+      const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM);
 
-      action.subscribe({
-        error() {
-          component.nsForm.setErrors({ cdSubmitButton: true });
-        },
-        complete: () => {
-          this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
-        }
+      // Step 1: Create namespaces
+      // Step 2: If specific hosts selected, chain addNamespaceInitiators calls
+      const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible));
+
+      const combinedObs = createObs.pipe(
+        switchMap((responses: HttpResponse<Object>[]) => {
+          if (noAutoVisible && selectedHosts.length > 0) {
+            const initiatorObs: Observable<any>[] = [];
+
+            responses.forEach((res) => {
+              const body: any = res.body;
+              if (body && body.nsid) {
+                selectedHosts.forEach((host: string) => {
+                  const req: NamespaceInitiatorRequest = {
+                    gw_group: this.group,
+                    subsystem_nqn: subsystemNQN || this.subsystemNQN,
+                    host_nqn: host
+                  };
+                  initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req));
+                });
+              }
+            });
+
+            if (initiatorObs.length > 0) {
+              return forkJoin(initiatorObs);
+            }
+          }
+          return of(responses);
+        })
+      );
+
+      action = this.taskWrapperService.wrapTaskAroundCall({
+        task: new FinishedTask(taskUrl, {
+          nqn: subsystemNQN,
+          nsCount
+        }),
+        call: combinedObs
       });
     }
+
+    action.subscribe({
+      error: () => {
+        component.nsForm.setErrors({ cdSubmitButton: true });
+      },
+      complete: () => {
+        this.router.navigate([this.pageURL], {
+          queryParams: { group: this.group, tab: 'namespace' }
+        });
+      }
+    });
   }
 }
index 58853ad8cd5429c853899a8cb5928fbe722a7e9f..7687318f953c5c141a8ead38dff20bbb24a38e4c 100644 (file)
@@ -5,6 +5,7 @@ import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
 import { Icons } from '~/app/shared/enum/icons.enum';
+
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { FinishedTask } from '~/app/shared/models/finished-task';
@@ -14,6 +15,7 @@ import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
 import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
@@ -94,11 +96,14 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
         name: this.actionLabels.CREATE,
         permission: 'create',
         icon: Icons.add,
-        click: () =>
-          this.router.navigate(
-            [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }],
-            { queryParams: { group: this.group } }
-          ),
+        click: () => {
+          this.router.navigate(['block/nvmeof/namespaces/create'], {
+            queryParams: {
+              group: this.group,
+              subsystem_nqn: this.subsystemNQN
+            }
+          });
+        },
         canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
         disable: () => !this.group
       },
@@ -110,16 +115,10 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
           this.router.navigate(
             [
               BASE_URL,
-              {
-                outlets: {
-                  modal: [
-                    URLVerbs.EDIT,
-                    this.subsystemNQN,
-                    'namespace',
-                    this.selection.first().nsid
-                  ]
-                }
-              }
+              URLVerbs.EDIT,
+              this.selection.first().ns_subsystem_nqn,
+              'namespace',
+              this.selection.first().nsid
             ],
             { queryParams: { group: this.group } }
           )
@@ -139,7 +138,15 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
         }
         return this.nvmeofService.listNamespaces(this.group).pipe(
           map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) => {
-            return Array.isArray(res) ? res : res.namespaces || [];
+            const namespaces = Array.isArray(res) ? res : res.namespaces || [];
+            // Deduplicate by nsid + subsystem NQN (API with wildcard can return duplicates per gateway)
+            const seen = new Set<string>();
+            return namespaces.filter((ns) => {
+              const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
+              if (seen.has(key)) return false;
+              seen.add(key);
+              return true;
+            });
           }),
           catchError(() => of([]))
         );
index 5873fc79caf05d0275dc260f89a0273b2431e510..44cc2b4db8d50cb6202f53bbc49f19c6899e8a72 100755 (executable)
@@ -167,7 +167,7 @@ describe('NvmeofService', () => {
         enable_ha: true,
         initiators: '*',
         gw_group: mockGroupName,
-        dhchap_key: null
+        dhchap_key: ''
       };
       service.createSubsystem(request).subscribe();
       const req = httpTesting.expectOne(`${API_PATH}/subsystem`);
@@ -198,12 +198,12 @@ describe('NvmeofService', () => {
       );
       expect(req.request.method).toBe('GET');
     });
-    it('should call addInitiators', () => {
+    it('should call addSubsystemInitiators', () => {
       service.addSubsystemInitiators(mockNQN, request).subscribe();
       const req = httpTesting.expectOne(`${UI_API_PATH}/subsystem/${mockNQN}/host`);
       expect(req.request.method).toBe('POST');
     });
-    it('should call removeInitiators', () => {
+    it('should call removeSubsystemInitiators', () => {
       service.removeSubsystemInitiators(mockNQN, request).subscribe();
       const req = httpTesting.expectOne(
         `${UI_API_PATH}/subsystem/${mockNQN}/host/${request.host_nqn}/${mockGroupName}`
index 3ede27f1ecb593f3313af7a82d39ea4aa345a20b..c396ae8b45c16ccdec6cd6107baf4f52cd3a33db 100644 (file)
@@ -4,6 +4,7 @@ import { HttpClient } from '@angular/common/http';
 import _ from 'lodash';
 import { Observable, forkJoin, of as observableOf } from 'rxjs';
 import { catchError, map, mapTo, mergeMap } from 'rxjs/operators';
+import { NvmeofSubsystemNamespace } from '../models/nvmeof';
 import { CephServiceSpec } from '../models/service.interface';
 import { HostService } from './host.service';
 import { OrchestratorService } from './orchestrator.service';
@@ -32,11 +33,12 @@ export type ListenerRequest = NvmeofRequest & {
 };
 
 export type NamespaceCreateRequest = NvmeofRequest & {
-  rbd_image_name: string;
+  rbd_image_name?: string;
   rbd_pool: string;
   rbd_image_size?: number;
   no_auto_visible?: boolean;
   create_image: boolean;
+  block_size?: number;
 };
 
 export type NamespaceUpdateRequest = NvmeofRequest & {
@@ -239,6 +241,11 @@ export class NvmeofService {
       }
     );
   }
+  listSubsystemNamespaces(subsystemNQN: string) {
+    return this.http.get<NvmeofSubsystemNamespace[]>(
+      `${API_PATH}/subsystem/${subsystemNQN}/namespace`
+    );
+  }
 
   // Namespaces
   listNamespaces(group: string, subsystemNQN: string = '*') {
index b55207934b727256d67f930286b76cf246792a6c..c7cfec1209e447fcc9ee8a01b061b4a34c93e05c 100644 (file)
@@ -114,3 +114,30 @@ export function getSubsystemAuthStatus(
 
   return NO_AUTH;
 }
+
+// Form control names for NvmeofNamespacesFormComponent
+export enum NsFormField {
+  POOL = 'pool',
+  SUBSYSTEM = 'subsystem',
+  IMAGE_SIZE = 'image_size',
+  NS_COUNT = 'nsCount',
+  RBD_IMAGE_CREATION = 'rbd_image_creation',
+  RBD_IMAGE_NAME = 'rbd_image_name',
+  NAMESPACE_SIZE = 'namespace_size',
+  HOST_ACCESS = 'host_access',
+  INITIATORS = 'initiators'
+}
+
+export enum RbdImageCreation {
+  GATEWAY_PROVISIONED = 'gateway_provisioned',
+  EXTERNALLY_MANAGED = 'externally_managed'
+}
+
+export type NvmeofNamespaceListResponse =
+  | NvmeofSubsystemNamespace[]
+  | { namespaces: NvmeofSubsystemNamespace[] };
+
+export type NvmeofInitiatorCandidate = {
+  content: string;
+  selected: boolean;
+};