]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Nvmeof edit namespace size 67328/head
authorSagar Gopale <sagar.gopale@ibm.com>
Thu, 12 Feb 2026 12:42:34 +0000 (18:12 +0530)
committerSagar Gopale <sagar.gopale@ibm.com>
Tue, 24 Feb 2026 17:28:29 +0000 (22:58 +0530)
Fixes: https://tracker.ceph.com/issues/74900
Signed-off-by: Sagar Gopale <sagar.gopale@ibm.com>
12 files changed:
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.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.ts [new file with mode: 0644]
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/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts

index c995622d357b088c0a93ca72c673c699f5fb3547..edb4a1ec7d3de478f103e7bf7bf5b05026b4d060 100644 (file)
@@ -88,7 +88,9 @@ import Reset from '@carbon/icons/es/reset/32';
 import SubtractAlt from '@carbon/icons/es/subtract--alt/20';
 import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
 import Search from '@carbon/icons/es/search/32';
+import Datastore from '@carbon/icons/es/datastore/16';
 import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component';
+import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component';
 import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
 import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
 import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
@@ -176,6 +178,7 @@ import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem
     NvmeGatewayViewComponent,
     NvmeofGatewaySubsystemComponent,
     NvmeofGatewayNodeAddModalComponent,
+    NvmeofNamespaceExpandModalComponent,
     NvmeSubsystemViewComponent,
     NvmeofEditHostKeyModalComponent
   ],
@@ -192,7 +195,8 @@ export class BlockModule {
       Reset,
       ProgressBarRound,
       SubtractAlt,
-      Search
+      Search,
+      Datastore
     ]);
   }
 }
@@ -333,7 +337,18 @@ const routes: Routes = [
     },
     children: [
       { path: '', redirectTo: 'gateways', pathMatch: 'full' },
-      { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } },
+      {
+        path: 'gateways',
+        component: NvmeofGatewayComponent,
+        data: { breadcrumbs: 'Gateways' },
+        children: [
+          {
+            path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
+            component: NvmeofNamespaceExpandModalComponent,
+            outlet: 'modal'
+          }
+        ]
+      },
       {
         path: `gateways/${URLVerbs.CREATE}`,
         component: NvmeofGroupFormComponent,
@@ -369,7 +384,7 @@ const routes: Routes = [
         data: { breadcrumbs: 'Subsystems' },
         children: [
           // subsystems
-          { path: '', component: NvmeofSubsystemsComponent },
+
           {
             path: URLVerbs.CREATE,
             component: NvmeofSubsystemsFormComponent,
@@ -389,8 +404,8 @@ const routes: Routes = [
           },
           {
             path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
-            component: NvmeofNamespacesFormComponent,
-            data: { breadcrumbs: ActionLabels.EDIT + ' ' + $localize`Namespace` }
+            component: NvmeofNamespaceExpandModalComponent,
+            outlet: 'modal'
           },
           // initiators
           {
index 523c750caf16b2d4da2de9051f39eb6e403f5b0f..a17657ae49e9586e4d2039e4355867281c22dbe1 100644 (file)
@@ -44,4 +44,5 @@
   <cd-nvmeof-namespaces-list></cd-nvmeof-namespaces-list>
 </ng-template>
 </section>
+<router-outlet name="modal"></router-outlet>
 
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.html
new file mode 100644 (file)
index 0000000..15aa4ce
--- /dev/null
@@ -0,0 +1,64 @@
+<cds-modal size="sm"
+           [open]="true"
+           (overlaySelected)="closeModal()">
+  <cds-modal-header (closeSelect)="closeModal()">
+    <h4 cdsModalHeaderLabel
+        class="cds--type-label-01"
+        i18n>Namespace</h4>
+    <h3 cdsModalHeaderHeading
+        class="cds--type-heading-03"
+        i18n>Expand namespace</h3>
+  </cds-modal-header>
+
+  <section cdsModalContent>
+    <div class="cds--type-body-01 cds-mb-3"
+         i18n>
+      Increase the NVMe namespace storage capacity by resizing the backing image.
+    </div>
+
+    <div class="cds--type-body-01 cds-mb-5">
+      <div class="cds-mb-3">
+        <svg [cdsIcon]="icons.datastore"
+             size="16"
+             class="cds-mr-3"></svg>
+        <strong><span i18n>namespace</span>-{{ nsid }}</strong>
+      </div>
+      <div class="cds--type-helper-text-01 cds-mb-1">
+        <span i18n>Image:</span> {{ imageName }}
+      </div>
+      <div class="cds--type-helper-text-01">
+        <span i18n>Current size:</span> {{ currentBytes | dimlessBinary }}
+      </div>
+    </div>
+
+    <form name="nsForm"
+          #formDir="ngForm"
+          [formGroup]="nsForm"
+          novalidate>
+      <div class="form-item">
+        <cds-number label="Enter the new size of the namespace image (GiB)"
+                    i18n-label
+                    [formControlName]="'image_size'"
+                    [invalid]="nsForm.controls['image_size'].invalid && (nsForm.controls['image_size'].dirty || nsForm.controls['image_size'].touched)"
+                    [invalidText]="sizeErrorRef"
+                    [min]="minSize"
+                    required
+                    modal-primary-focus></cds-number>
+        <ng-template #sizeErrorRef>
+          <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('image_size') }"></ng-container>
+        </ng-template>
+      </div>
+    </form>
+
+    <ng-template #validationErrors
+                 let-control="control">
+    @for (err of control.errors | keyvalue; track err.key) {
+      <span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
+    }
+    </ng-template>
+  </section>
+
+  <cd-form-button-panel (submitActionEvent)="onSubmit()"
+                        [submitText]="expandText"
+                        [modalForm]="true"></cd-form-button-panel>
+</cds-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..f5e249c
--- /dev/null
@@ -0,0 +1,94 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal.component';
+import { ActivatedRoute } from '@angular/router';
+import { ActivatedRouteStub } from '~/testing/activated-route-stub';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+import { ModalModule, NumberModule } from 'carbon-components-angular';
+import { of } from 'rxjs';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+describe('NvmeofNamespaceExpandModalComponent', () => {
+  let component: NvmeofNamespaceExpandModalComponent;
+  let fixture: ComponentFixture<NvmeofNamespaceExpandModalComponent>;
+  let nvmeofService: NvmeofService;
+
+  const mockNvmeofService = {
+    getNamespace: () =>
+      of({
+        nsid: '1',
+        rbd_pool_name: 'pool1',
+        rbd_image_name: 'image1',
+        rbd_image_size: new FormatterService().toBytes('1GiB'),
+        block_size: 4096,
+        rw_ios_per_second: 0,
+        rw_mbytes_per_second: 0,
+        r_mbytes_per_second: 0,
+        w_mbytes_per_second: 0
+      }),
+    updateNamespace: () => of({})
+  };
+
+  const activatedRouteStub = new ActivatedRouteStub(
+    { subsystem_nqn: 'nqn.2014-08.org.nvmexpress:uuid:12345', nsid: '1' },
+    { group: 'group1' }
+  );
+  // Mock the parent route for relative navigation
+  Object.defineProperty(activatedRouteStub, 'parent', { get: () => ({}) });
+
+  configureTestBed({
+    declarations: [NvmeofNamespaceExpandModalComponent],
+    imports: [
+      HttpClientTestingModule,
+      SharedModule,
+      ReactiveFormsModule,
+      RouterTestingModule,
+      ToastrModule.forRoot(),
+      ModalModule,
+      NumberModule
+    ],
+    providers: [
+      { provide: NvmeofService, useValue: mockNvmeofService },
+      { provide: ActivatedRoute, useValue: activatedRouteStub }
+    ]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeofNamespaceExpandModalComponent);
+    component = fixture.componentInstance;
+    nvmeofService = TestBed.inject(NvmeofService);
+
+    // params are already set in constructor of stub above
+
+    spyOn(nvmeofService, 'getNamespace').and.callThrough();
+    spyOn(nvmeofService, 'updateNamespace').and.callThrough();
+
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should initialize form with existing data', () => {
+    expect(component.nsForm.get('image_size').value).toBe(1);
+  });
+
+  it('should validate size - error if smaller', () => {
+    component.nsForm.get('image_size').setValue(0.5);
+    component.nsForm.get('image_size').updateValueAndValidity();
+    expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true);
+  });
+
+  it('should validate size - success if larger', () => {
+    component.nsForm.get('image_size').setValue(2);
+    component.nsForm.get('image_size').updateValueAndValidity();
+    expect(component.nsForm.get('image_size').hasError('minSize')).toBe(false);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.ts
new file mode 100644 (file)
index 0000000..540f3ba
--- /dev/null
@@ -0,0 +1,142 @@
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { combineLatest } from 'rxjs';
+
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService, NamespaceUpdateRequest } from '~/app/shared/api/nvmeof.service';
+import { ActionLabelsI18n } 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 { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { FormButtonPanelComponent } from '~/app/shared/components/form-button-panel/form-button-panel.component';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+
+@Component({
+  selector: 'cd-nvmeof-namespace-expand-modal',
+  templateUrl: './nvmeof-namespace-expand-modal.component.html',
+  styleUrls: ['./nvmeof-namespace-expand-modal.component.scss'],
+  standalone: false
+})
+export class NvmeofNamespaceExpandModalComponent implements OnInit {
+  subsystemNQN: string;
+  nsid: string;
+  group: string;
+
+  nsForm: CdFormGroup;
+  currentBytes: number;
+  currentSizeGiB: number;
+  imageName: string;
+  expandText: string = $localize`Expand`;
+  icons = Icons;
+  INVALID_TEXTS: Record<string, string> = {
+    required: $localize`This field is required.`,
+    minSize: $localize`Value must be greater than the current image size.`
+  };
+
+  minSize: number = 0;
+
+  @ViewChild(FormButtonPanelComponent)
+  formButtonPanel: FormButtonPanelComponent;
+
+  constructor(
+    public actionLabels: ActionLabelsI18n,
+    private nvmeofService: NvmeofService,
+    private route: ActivatedRoute,
+    private router: Router,
+    public taskWrapper: TaskWrapperService,
+    private formatter: FormatterService
+  ) {}
+
+  ngOnInit() {
+    this.createForm();
+    combineLatest([this.route.params, this.route.queryParams]).subscribe(
+      ([params, queryParams]) => {
+        this.subsystemNQN = params['subsystem_nqn'];
+        this.nsid = params['nsid'];
+        this.group = queryParams['group'];
+
+        if (this.subsystemNQN && this.nsid && this.group) {
+          this.initForEdit();
+        }
+      }
+    );
+  }
+
+  createForm() {
+    this.nsForm = new CdFormGroup({
+      image_size: new UntypedFormControl(null, {
+        validators: [
+          Validators.required,
+          CdValidators.custom('minSize', (value: any) => {
+            if (this.currentBytes && value !== null && value !== undefined) {
+              const bytes = this.formatter.toBytes(`${value}GiB`);
+              if (bytes <= this.currentBytes) {
+                return { minSize: true };
+              }
+            }
+            return null;
+          })
+        ]
+      })
+    });
+  }
+
+  initForEdit() {
+    this.nvmeofService
+      .getNamespace(this.subsystemNQN, this.nsid, this.group)
+      .subscribe((res: NvmeofSubsystemNamespace) => {
+        this.currentBytes =
+          typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
+        this.imageName = res.rbd_image_name;
+        this.currentSizeGiB = this.currentBytes / this.formatter.toBytes('1GiB');
+        this.minSize = this.currentSizeGiB;
+        this.nsForm.patchValue({
+          image_size: this.currentSizeGiB
+        });
+        this.nsForm.get('image_size').updateValueAndValidity();
+      });
+  }
+
+  closeModal() {
+    this.router.navigate([{ outlets: { modal: null } }], {
+      relativeTo: this.route.parent,
+      queryParamsHandling: 'preserve'
+    });
+  }
+
+  onSubmit() {
+    if (this.nsForm.invalid) {
+      this.nsForm.markAllAsTouched();
+      this.nsForm.setErrors({ cdSubmitButton: true });
+      if (this.formButtonPanel?.submitButton) {
+        this.formButtonPanel.submitButton.loading = false;
+      }
+      return;
+    }
+
+    const image_size = this.nsForm.getValue('image_size');
+    const rbdImageSize = this.formatter.toBytes(`${image_size}GiB`);
+
+    const request: NamespaceUpdateRequest = {
+      gw_group: this.group,
+      rbd_image_size: rbdImageSize
+    };
+
+    this.taskWrapper
+      .wrapTaskAroundCall({
+        task: new FinishedTask('nvmeof/namespace/edit', {
+          nqn: this.subsystemNQN,
+          nsid: this.nsid
+        }),
+        call: this.nvmeofService.updateNamespace(this.subsystemNQN, this.nsid, request)
+      })
+      .subscribe({
+        complete: () => {
+          this.closeModal();
+        }
+      });
+  }
+}
index 4200fd73622e3467a548830bbfcf25c17676093e..6c6f11fdf75cf316cfba7c7aba36cabfdb4db0cd 100644 (file)
@@ -1,4 +1,6 @@
 <form
+  name="nsForm"
+  #formDir="ngForm"
   [formGroup]="nsForm"
   novalidate>
   <div cdsGrid
@@ -11,7 +13,7 @@
       <div cdsRow
            class="form-heading form-item">
         <h3>{{ action | titlecase }} {{ resource | titlecase }}</h3>
-        <cd-help-text [formAllFieldsRequired]="true">
+        <cd-help-text>
           <span i18n>
             Namespaces define the storage volumes that subsystems present to hosts.
           </span>
       </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"
+            [invalid]="nsForm.controls['nsCount'].invalid && (nsForm.controls['nsCount'].dirty || nsForm.controls['nsCount'].touched)"
+            [invalidText]="nsForm.getError('required', 'nsCount') ? requiredInvalidText : nsCountInvalidText"
             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) {
+      <!-- Namespace Size (UI only) -->
       <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."
+            [invalidText]="namespaceSizeError"
             i18n-label
-            i18n-helperText
-            i18n-invalidText>
+            i18n-helperText>
           </cds-number>
+          <ng-template #namespaceSizeError>
+            @if (nsForm.controls['namespace_size'].hasError('blockSizeMultiple')) {
+            <span
+              i18n>Size must be a multiple of the block size (typically 512 or 4096 bytes).</span>
+            }
+          </ng-template>
         </div>
       </div>
-      }
 
-      <!-- Host Access (drives no_auto_visible in create request) -->
-      @if (!edit) {
+      <!-- Host Access (UI only) -->
       <div cdsRow
            class="form-item">
         <div cdsCol>
           </div>
         </div>
       </div>
-      }
 
       <!-- Host Selection (Visible only when 'specific' is selected) -->
-      @if (!edit && nsForm.getValue('host_access') === 'specific') {
+      @if (nsForm.getValue('host_access') === 'specific') {
       <div cdsRow
            class="form-item">
         <div cdsCol>
               [items]="initiatorCandidates"
               (selected)="onInitiatorSelection($event)"
               [invalid]="nsForm.controls['initiators'].invalid && (nsForm.controls['initiators'].dirty || nsForm.controls['initiators'].touched)"
-              invalidText="This field is required."
+              invalidText="This field is required"
               i18n-invalidText
               i18n-placeholder>
               <cds-dropdown-list></cds-dropdown-list>
       <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."
+            [invalid]="nsForm.controls['subsystem'].invalid && (nsForm.controls['subsystem'].dirty || nsForm.controls['subsystem'].touched)"
+            invalidText="This field is required"
             i18n-label
             i18n-invalidText>
             @if (subsystems === undefined) {
             }
             @if (subsystems && subsystems.length > 0) {
             <option
+              selectionFeedback="top-after-reopen"
               value=""
               selected>Select a subsystem</option>
             }
               [value]="subsystem.nqn">{{ subsystem.nqn }}</option>
             }
           </cds-select>
-          }
         </div>
       </div>
 
       <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."
+            [invalid]="nsForm.controls['pool'].invalid && (nsForm.controls['pool'].dirty || nsForm.controls['pool'].touched)"
+            invalidText="This field is required"
             helperText="Pool where the backing Ceph block device resides."
             i18n-label
             i18n-invalidText
               [value]="pool.pool_name">{{ pool.pool_name }}</option>
             }
           </cds-select>
-          }
         </div>
       </div>
 
-      <!-- RBD image creation (drives create_image flag in request) -->
-      @if (!edit) {
+      <!-- RBD image creation (UI only) -->
       <div cdsRow
            class="form-item">
         <div cdsCol>
           </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) {
+      <!-- Image Name (Visible only when 'gateway_provisioned' is selected) -->
+      @if (nsForm.getValue('rbd_image_creation') === 'gateway_provisioned' && nsForm.getValue('nsCount') > 1) {
       <div cdsRow
            class="form-item">
         <div cdsCol>
             <strong i18n>For bulk namespace creation, RBD images are provisioned automatically.</strong>
           </cd-alert-panel>
 
-          <div class="form-item"
-               cdOptionalField="Image name">
+          <div class="form-item">
             <label
-              class="cds--type-label-01 cds--label"
-              i18n>Image name</label>
+              class="cds--type-label-01"
+              i18n>Image name (optional)</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>
+              [invalid]="nsForm.controls['rbd_image_name'].invalid && (nsForm.controls['rbd_image_name'].dirty || nsForm.controls['rbd_image_name'].touched)"
+              [invalidText]="nsForm.getError('rbdImageName', 'rbd_image_name') ? 'Image name contains invalid characters' : ''"
+              i18n-helperText
+              i18n-invalidText>
               <input cdsText
                      placeholder="Enter a name"
-                     formControlName="rbd_image_name"
-                     cdValidate
-                     #rbdImageNameRef="cdValidate"
-                     [invalid]="rbdImageNameRef.isInvalid" />
+                     formControlName="rbd_image_name" />
             </cds-text-label>
-            <ng-template #rbdImageNameError>
-              <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('rbd_image_name') }"></ng-container>
-            </ng-template>
           </div>
         </div>
       </div>
       }
 
       <!-- Image Selection (Visible only when 'externally_managed' is selected) -->
-      @if (!edit && nsForm.getValue('rbd_image_creation') === 'externally_managed') {
+      @if (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."
+            [invalid]="nsForm.controls['rbd_image_name'].invalid && (nsForm.controls['rbd_image_name'].dirty || nsForm.controls['rbd_image_name'].touched)"
+            invalidText="This field is required"
             helperText="Select an existing RBD image from the pool to expose as a namespace."
             i18n-label
             i18n-invalidText
       }
 
       <!-- Image Size -->
-      @if (!edit && nsForm.getValue('rbd_image_creation') !== 'externally_managed') {
       <div cdsRow
            class="form-item">
         <div
           cdsCol
           [columnNumbers]="{md: 4}">
           <cds-text-label
+            label="Image size (GiB)"
             helperText="The size of the namespace image."
-            [invalid]="imageSizeRef.isInvalid"
+            [invalid]="nsForm.controls['image_size'].invalid && (nsForm.controls['image_size'].dirty || nsForm.controls['image_size'].touched)"
             [invalidText]="sizeError"
+            cdRequiredField="Image Size"
+            i18n-label
             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"
+                   [invalid]="nsForm.controls['image_size'].invalid && (nsForm.controls['image_size'].dirty || nsForm.controls['image_size'].touched)"
                    cdDimlessBinary>
           </cds-text-label>
           <ng-template #sizeError>
-            <ng-container *ngTemplateOutlet="validationErrors; context: { control: nsForm.get('image_size') }"></ng-container>
+            <span
+              i18n>Enter a valid size (e.g., 10GiB).</span>
           </ng-template>
         </div>
       </div>
-      }
 
       <div cdsRow>
         <cd-form-button-panel
   </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 7ad2aec4e64ad8676e1ed95c010a86a97886db1f..ddefda27c796bf3d327644a9fa92c8d4062dfda8 100644 (file)
@@ -10,18 +10,17 @@ import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
 
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { SharedModule } from '~/app/shared/shared.module';
-
 import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form.component';
 import { FormHelper, Mocks } from '~/testing/unit-test-helper';
+import { FormatterService } from '~/app/shared/services/formatter.service';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { of, Observable } from 'rxjs';
 import { PoolService } from '~/app/shared/api/pool.service';
 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']),
@@ -47,7 +46,7 @@ const MOCK_NS_RESPONSE = {
   rbd_image_name: 'nvme_rbd_default_sscfagwuvvr',
   rbd_pool_name: 'rbd',
   load_balancing_group: 1,
-  rbd_image_size: '1073741824',
+  rbd_image_size: new FormatterService().toBytes('1GiB').toString(),
   block_size: 512,
   rw_ios_per_second: '0',
   rw_mbytes_per_second: '0',
@@ -130,7 +129,7 @@ describe('NvmeofNamespacesFormComponent', () => {
     });
     it('should create 5 namespaces correctly', () => {
       formHelper.setValue('pool', 'rbd');
-      formHelper.setValue('image_size', 1073741824);
+      formHelper.setValue('image_size', new FormatterService().toBytes('1GiB'));
       formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
       component.onSubmit();
       expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5);
@@ -139,26 +138,10 @@ describe('NvmeofNamespacesFormComponent', () => {
         rbd_image_name: `nvme_rbd_default_${MOCK_RANDOM_STRING}`,
         rbd_pool: 'rbd',
         create_image: true,
-        rbd_image_size: 1073741824,
+        rbd_image_size: new FormatterService().toBytes('1GiB'),
         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();
@@ -183,7 +166,7 @@ describe('NvmeofNamespacesFormComponent', () => {
 
     it('should call addNamespaceInitiators on submit with specific hosts', () => {
       formHelper.setValue('pool', 'rbd');
-      formHelper.setValue('image_size', 1073741824);
+      formHelper.setValue('image_size', new FormatterService().toBytes('1GiB'));
       formHelper.setValue('subsystem', MOCK_SUBSYSTEM);
       formHelper.setValue('host_access', 'specific');
       formHelper.setValue('initiators', ['host1']);
@@ -199,66 +182,10 @@ describe('NvmeofNamespacesFormComponent', () => {
     });
 
     it('should update initiators form control on selection', () => {
-      const mockEvent: NvmeofInitiatorCandidate[] = [
-        { content: 'host1', selected: true },
-        { content: 'host2', selected: true }
-      ];
+      const mockEvent = [{ content: 'host1' }, { content: 'host2' }];
       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', () => {
-    beforeEach(() => {
-      router = TestBed.inject(Router);
-      nvmeofService = TestBed.inject(NvmeofService);
-      spyOn(nvmeofService, 'getNamespace').and.returnValue(of(MOCK_NS_RESPONSE));
-      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);
-      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 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.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('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 GiB');
-      component.onSubmit();
-      expect(nvmeofService.updateNamespace).toHaveBeenCalledTimes(1);
-      expect(nvmeofService.updateNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, MOCK_NSID, {
-        gw_group: MOCK_GROUP,
-        rbd_image_size: 2147483648
-      });
-    });
-  });
 });
index 1a6e47a9a8365e90454c9807228a705cd22c62e7..2a185720eebc717ae1c633e9f5deec18a51c781a 100644 (file)
@@ -1,10 +1,9 @@
-import { Component, OnDestroy, OnInit } from '@angular/core';
+import { Component, OnInit } from '@angular/core';
 import { UntypedFormControl, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 import {
   NamespaceCreateRequest,
   NamespaceInitiatorRequest,
-  NamespaceUpdateRequest,
   NvmeofService
 } from '~/app/shared/api/nvmeof.service';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
@@ -13,23 +12,17 @@ import { FinishedTask } from '~/app/shared/models/finished-task';
 import {
   NvmeofSubsystem,
   NvmeofSubsystemInitiator,
-  NvmeofSubsystemNamespace,
-  NvmeofNamespaceListResponse,
-  NvmeofInitiatorCandidate,
-  NsFormField,
-  RbdImageCreation,
-  HOST_TYPE
+  NvmeofSubsystemNamespace
 } 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, of, Subject } from 'rxjs';
-import { filter, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { forkJoin, Observable, of } from 'rxjs';
+import { switchMap } 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';
@@ -40,39 +33,27 @@ import { HttpResponse } from '@angular/common/http';
   styleUrls: ['./nvmeof-namespaces-form.component.scss'],
   standalone: false
 })
-export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
+export class NvmeofNamespacesFormComponent implements OnInit {
   action: string;
   permission: Permission;
   poolPermission: Permission;
   resource: string;
   pageURL: string;
-  edit: boolean = false;
+
   nsForm: CdFormGroup;
   subsystemNQN: string;
-  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;
+  subsystems: NvmeofSubsystem[];
+  rbdPools: Array<Pool> = null;
+  rbdImages: any[] = [];
+  initiatorCandidates: { content: string; selected: boolean }[] = [];
 
   nsid: string;
-  currentBytes: number = 0;
+
   group: string;
   MAX_NAMESPACE_CREATE: number = 5;
   MIN_NAMESPACE_CREATE: number = 1;
-  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.`
-  };
+  requiredInvalidText: string = $localize`This field is required`;
+  nsCountInvalidText: string = $localize`The namespace count should be between 1 and 5`;
 
   constructor(
     public actionLabels: ActionLabelsI18n,
@@ -92,78 +73,45 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
     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 }) => {
       this.subsystemNQN = params.subsystem_nqn;
       this.nsid = params?.nsid;
     });
-  }
-
-  initForEdit() {
-    this.edit = true;
-    this.action = this.actionLabels.EDIT;
-    this.nvmeofService
-      .getNamespace(this.subsystemNQN, this.nsid, this.group)
-      .subscribe((res: NvmeofSubsystemNamespace) => {
-        this.currentBytes =
-          typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
-        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);
-      });
+    this.route.queryParams.subscribe((params) => {
+      if (params?.['subsystem_nqn']) {
+        this.subsystemNQN = params?.['subsystem_nqn'];
+      }
+    });
   }
 
   initForCreate() {
     this.poolService.getList().subscribe((resp: Pool[]) => {
       this.rbdPools = resp.filter(this.rbdService.isRBDPool);
     });
-    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[]) => {
+    if (this.group) {
+      this.fetchUsedImages();
+      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);
+            this.nsForm.get('subsystem').setValue(selectedSubsystem.nqn);
           }
         }
       });
+    }
   }
 
   ngOnInit() {
     this.init();
-    if (this.router.url.includes('subsystems/(modal:edit')) {
-      this.initForEdit();
-    } else {
-      this.initForCreate();
-    }
-    const subsystemControl = this.nsForm.get(NsFormField.SUBSYSTEM);
+    this.initForCreate();
+    const subsystemControl = this.nsForm.get('subsystem');
     if (subsystemControl) {
       subsystemControl.valueChanges.subscribe((nqn: string) => {
         this.onSubsystemChange(nqn);
@@ -171,19 +119,24 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
     }
   }
 
+  // Stores all RBD images fetched for the selected pool
+  private allRbdImages: { name: string; size: number }[] = [];
+  // Maps pool name to a Set of used image names for O(1) lookup
+  private usedRbdImages: Map<string, Set<string>> = new Map();
+
   onPoolChange(): void {
-    const pool = this.nsForm.getValue(NsFormField.POOL);
+    const pool = this.nsForm.getValue('pool');
     if (!pool) return;
 
     this.rbdService
       .list({ pool_name: pool, offset: '0', limit: '-1' })
-      .subscribe((pools: RbdPool[]) => {
+      .subscribe((pools: { pool_name: string; value: { name: string; size: number }[] }[]) => {
         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);
+        const imageControl = this.nsForm.get('rbd_image_name');
+        const currentImage = this.nsForm.getValue('rbd_image_name');
         if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) {
           imageControl.setValue(null);
         }
@@ -195,26 +148,23 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
   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();
-      });
+    this.nvmeofService.listNamespaces(this.group).subscribe((response: any) => {
+      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;
+    if (!nqn) return;
     this.nvmeofService
       .getInitiators(nqn, this.group)
       .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
@@ -226,18 +176,18 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
       });
   }
 
-  onInitiatorSelection(event: NvmeofInitiatorCandidate[]) {
+  onInitiatorSelection(event: any) {
     // Carbon ComboBox (selected) emits the full array of selected items
-    const selectedInitiators = Array.isArray(event) ? event.map((e) => e.content) : [];
+    const selectedInitiators = Array.isArray(event) ? event.map((e: any) => e.content) : [];
     this.nsForm
-      .get(NsFormField.INITIATORS)
+      .get('initiators')
       .setValue(selectedInitiators.length > 0 ? selectedInitiators : null);
-    this.nsForm.get(NsFormField.INITIATORS).markAsDirty();
-    this.nsForm.get(NsFormField.INITIATORS).markAsTouched();
+    this.nsForm.get('initiators').markAsDirty();
+    this.nsForm.get('initiators').markAsTouched();
   }
 
   private filterImages(): void {
-    const pool = this.nsForm.getValue(NsFormField.POOL);
+    const pool = this.nsForm.getValue('pool');
     if (!pool) {
       this.rbdImages = [];
       return;
@@ -250,120 +200,82 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
 
   createForm() {
     this.nsForm = new CdFormGroup({
-      [NsFormField.POOL]: new UntypedFormControl('', {
+      pool: new UntypedFormControl('', {
         validators: [Validators.required]
       }),
-      [NsFormField.SUBSYSTEM]: new UntypedFormControl('', {
+      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;
-          })
-        ],
+      image_size: new UntypedFormControl(null, {
+        validators: [Validators.required],
         updateOn: 'blur'
       }),
-      [NsFormField.NS_COUNT]: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
+      nsCount: 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
-      ),
+      rbd_image_creation: new UntypedFormControl('gateway_provisioned'),
 
-      [NsFormField.RBD_IMAGE_NAME]: new UntypedFormControl(null, [
+      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
+      namespace_size: new UntypedFormControl(null, {
+        validators: [CdValidators.blockSizeMultiple()]
+      }), // UI only - not sent to backend
+      host_access: new UntypedFormControl('all'), // UI only - determines visibility
+      initiators: new UntypedFormControl([]) // UI only - selected hosts
     });
 
-    this.nsForm
-      .get(NsFormField.POOL)
-      .valueChanges.pipe(takeUntil(this.destroy$))
-      .subscribe(() => {
-        this.onPoolChange();
-      });
+    this.nsForm.get('pool').valueChanges.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('nsCount').valueChanges.subscribe((count: number) => {
+      if (count > 1) {
+        const creationControl = this.nsForm.get('rbd_image_creation');
+        if (creationControl.value === 'externally_managed') {
+          creationControl.setValue('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('rbd_image_creation').valueChanges.subscribe((mode: string) => {
+      const nameControl = this.nsForm.get('rbd_image_name');
+      const sizeControl = this.nsForm.get('image_size');
+      const countControl = this.nsForm.get('nsCount');
 
-    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();
-      });
-  }
+      if (mode === 'externally_managed') {
+        countControl.setValue(1);
+        countControl.disable();
+        this.onPoolChange();
+        nameControl.addValidators(Validators.required);
+        sizeControl.removeValidators(Validators.required);
+        sizeControl.disable();
+      } else {
+        sizeControl.enable();
+        countControl.enable();
+        nameControl.removeValidators(Validators.required);
+        sizeControl.addValidators(Validators.required);
+      }
+      nameControl.updateValueAndValidity();
+      sizeControl.updateValueAndValidity();
+    });
 
-  buildUpdateRequest(rbdImageSize: number): Observable<HttpResponse<Object>> {
-    const request: NamespaceUpdateRequest = {
-      gw_group: this.group,
-      rbd_image_size: rbdImageSize
-    };
-    return this.nvmeofService.updateNamespace(
-      this.subsystemNQN,
-      this.nsid,
-      request as NamespaceUpdateRequest
-    );
+    this.nsForm.get('host_access').valueChanges.subscribe((mode: string) => {
+      const initiatorsControl = this.nsForm.get('initiators');
+      if (mode === 'specific') {
+        initiatorsControl.addValidators(Validators.required);
+      } else {
+        initiatorsControl.removeValidators(Validators.required);
+        initiatorsControl.setValue([]);
+        this.initiatorCandidates.forEach((i) => (i.selected = false));
+      }
+      initiatorsControl.updateValueAndValidity();
+    });
   }
 
   randomString() {
@@ -375,10 +287,10 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
     nsCount: number,
     noAutoVisible: boolean
   ): Observable<HttpResponse<Object>>[] {
-    const pool = this.nsForm.getValue(NsFormField.POOL);
+    const pool = this.nsForm.getValue('pool');
     const requests: Observable<HttpResponse<Object>>[] = [];
-    const creationMode = this.nsForm.getValue(NsFormField.RBD_IMAGE_CREATION);
-    const isGatewayProvisioned = creationMode === RbdImageCreation.GATEWAY_PROVISIONED;
+    const creationMode = this.nsForm.getValue('rbd_image_creation');
+    const isGatewayProvisioned = creationMode === 'gateway_provisioned';
 
     const loopCount = isGatewayProvisioned ? nsCount : 1;
 
@@ -390,7 +302,7 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
         no_auto_visible: noAutoVisible
       };
 
-      const blockSize = this.nsForm.getValue(NsFormField.NAMESPACE_SIZE);
+      const blockSize = this.nsForm.getValue('namespace_size');
       if (blockSize) {
         request.block_size = blockSize;
       }
@@ -402,12 +314,12 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
         }
       }
 
-      const rbdImageName = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME);
+      const rbdImageName = this.nsForm.getValue('rbd_image_name');
       if (rbdImageName) {
-        request['rbd_image_name'] = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName;
+        request['rbd_image_name'] = rbdImageName;
       }
 
-      const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM) || this.subsystemNQN;
+      const subsystemNQN = this.nsForm.getValue('subsystem') || this.subsystemNQN;
       requests.push(this.nvmeofService.createNamespace(subsystemNQN, request));
     }
 
@@ -422,12 +334,12 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
     }
 
     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;
+    const taskUrl: string = `nvmeof/namespace/${URLVerbs.CREATE}`;
+    const image_size = this.nsForm.getValue('image_size');
+    const nsCount = this.nsForm.getValue('nsCount');
+    const hostAccess = this.nsForm.getValue('host_access');
+    const selectedHosts: string[] = this.nsForm.getValue('initiators') || [];
+    const noAutoVisible = hostAccess === 'specific';
     let action: Observable<any>;
     let rbdImageSize: number = null;
 
@@ -435,56 +347,46 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy {
       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 {
-      const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM);
-
-      // 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);
+    const subsystemNQN = this.nsForm.getValue('subsystem');
+
+    // 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
-      });
-    }
+        }
+        return of(responses);
+      })
+    );
+
+    action = this.taskWrapperService.wrapTaskAroundCall({
+      task: new FinishedTask(taskUrl, {
+        nqn: subsystemNQN,
+        nsCount
+      }),
+      call: combinedObs
+    });
 
     action.subscribe({
       error: () => {
index 7687318f953c5c141a8ead38dff20bbb24a38e4c..9f3969b7e4442591a7c8b66a599f8bf21504d880 100644 (file)
@@ -20,7 +20,6 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
 import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
 
-const BASE_URL = 'block/nvmeof/subsystems';
 const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
 
 @Component({
@@ -108,19 +107,28 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
         disable: () => !this.group
       },
       {
-        name: this.actionLabels.EDIT,
+        name: $localize`Expand`,
         permission: 'update',
         icon: Icons.edit,
         click: () =>
           this.router.navigate(
             [
-              BASE_URL,
-              URLVerbs.EDIT,
-              this.selection.first().ns_subsystem_nqn,
-              'namespace',
-              this.selection.first().nsid
+              {
+                outlets: {
+                  modal: [
+                    URLVerbs.EDIT,
+                    this.selection.first().ns_subsystem_nqn,
+                    'namespace',
+                    this.selection.first().nsid
+                  ]
+                }
+              }
             ],
-            { queryParams: { group: this.group } }
+            {
+              relativeTo: this.route,
+              queryParams: { group: this.group },
+              queryParamsHandling: 'merge'
+            }
           )
       },
       {
index bf0d514f72a3a910940977b39739c8508cf50311..eec24923f43baf40f2e12862d343f013ce96578c 100644 (file)
@@ -89,6 +89,7 @@ export enum Icons {
   connect = 'connect',
   checkmarkOutline = 'checkmark--outline',
   circleDash = 'circle-dash',
+  datastore = 'datastore',
   /* Icons for special effect */
   size16 = '16',
   size20 = '20',
index 8166386405bfe532f8405323fb9063a96030ecfc..997564a983c3c15f2cd923ae20e4c00265e213ad 100644 (file)
@@ -481,6 +481,22 @@ export class CdValidators {
     };
   }
 
+  /**
+   * Validator function to ensure the entered value is a multiple of a typical block size (512 or 4096).
+   * It checks the numeric value directly against the modulo 512 calculation.
+   */
+  static blockSizeMultiple(): ValidatorFn {
+    return (control: AbstractControl): { [key: string]: boolean } | null => {
+      const value = control.value;
+      if (value !== null && value !== undefined && value !== '') {
+        if (Number(value) % 512 !== 0) {
+          return { blockSizeMultiple: true };
+        }
+      }
+      return null;
+    };
+  }
+
   /**
    * Asynchronous validator that checks if the password meets the password
    * policy.