]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add namespace counter in nvmeof namepsaces 61752/head
authorAfreen Misbah <afreen@ibm.com>
Fri, 7 Feb 2025 09:36:20 +0000 (15:06 +0530)
committerAfreen Misbah <afreen@ibm.com>
Mon, 17 Feb 2025 09:05:51 +0000 (14:35 +0530)
Fixes https://tracker.ceph.com/issues/69900

Signed-off-by: Afreen Misbah <afreen@ibm.com>
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/shared/pipes/pluralize.pipe.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index 7832fa777de4cfc063815e76ad4176239672e291..f030e17dd94e66775ad61379c9c4b7e16c7c60f8 100644 (file)
           </label>
           <div class="cd-col-form-input">
             <input *ngIf="edit"
-                   name="pool"
                    class="form-control"
                    type="text"
                    formControlName="pool">
             <select *ngIf="!edit"
                     id="pool"
-                    name="pool"
                     class="form-select"
                     formControlName="pool">
               <option *ngIf="rbdPools === null"
                   i18n>This field is required.</span>
           </div>
         </div>
-        <!-- Image Name -->
-        <div class="form-group row">
+        <!-- Namespace Count -->
+        <div *ngIf="!edit"
+             class="form-group row">
           <label class="cd-col-form-label"
-                 for="image">
+                 for="nsCount">
             <span [ngClass]="{'required': !edit}"
-                  i18n>Image Name</span>
+                  i18n>Namespace Count</span>
           </label>
           <div class="cd-col-form-input">
-            <input name="image"
-                   class="form-control"
-                   type="text"
-                   formControlName="image">
-            <span class="invalid-feedback"
-                  *ngIf="nsForm.showError('image', formDir, 'required')">
-              <ng-container i18n>This field is required.</ng-container>
-            </span>
-            <span class="invalid-feedback"
-                  *ngIf="nsForm.showError('image', formDir, 'pattern')">
-              <ng-container i18n>'/' and '&#64;' are not allowed.</ng-container>
-            </span>
+            <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>
         </div>
         <!-- Image Size -->
               <input id="size"
                      class="form-control"
                      type="text"
-                     name="image_size"
                      formControlName="image_size">
               <select id="unit"
-                      name="unit"
                       class="form-input form-select"
                       formControlName="unit">
                 <option *ngFor="let u of units"
index 1cb2b0167069a1f5f72b1b24b73ab20504c7edf9..856fc19389df3f65d0c1bebd471800923f69b7a5 100644 (file)
@@ -11,8 +11,22 @@ 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 } from '~/testing/unit-test-helper';
+import { FormHelper, Mocks } from '~/testing/unit-test-helper';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { of } from 'rxjs';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { NumberModule } from 'carbon-components-angular';
+
+const mockPools = [
+  Mocks.getPool('pool-1', 1, ['cephfs']),
+  Mocks.getPool('rbd', 2),
+  Mocks.getPool('pool-2', 3)
+];
+class MockPoolService {
+  getList() {
+    return of(mockPools);
+  }
+}
 
 describe('NvmeofNamespacesFormComponent', () => {
   let component: NvmeofNamespacesFormComponent;
@@ -20,24 +34,24 @@ describe('NvmeofNamespacesFormComponent', () => {
   let nvmeofService: NvmeofService;
   let form: CdFormGroup;
   let formHelper: FormHelper;
-  const mockTimestamp = 1720693470789;
+  const mockRandomString = 1720693470789;
   const mockSubsystemNQN = 'nqn.2021-11.com.example:subsystem';
+  const mockGWgroup = 'default';
 
   beforeEach(async () => {
-    spyOn(Date, 'now').and.returnValue(mockTimestamp);
     await TestBed.configureTestingModule({
       declarations: [NvmeofNamespacesFormComponent],
-      providers: [NgbActiveModal],
+      providers: [NgbActiveModal, { provide: PoolService, useClass: MockPoolService }],
       imports: [
         HttpClientTestingModule,
         NgbTypeaheadModule,
         ReactiveFormsModule,
         RouterTestingModule,
         SharedModule,
+        NumberModule,
         ToastrModule.forRoot()
       ]
     }).compileComponents();
-
     fixture = TestBed.createComponent(NvmeofNamespacesFormComponent);
     component = fixture.componentInstance;
     component.ngOnInit();
@@ -53,32 +67,26 @@ describe('NvmeofNamespacesFormComponent', () => {
   describe('should test form', () => {
     beforeEach(() => {
       component.subsystemNQN = mockSubsystemNQN;
+      component.group = mockGWgroup;
       nvmeofService = TestBed.inject(NvmeofService);
       spyOn(nvmeofService, 'createNamespace').and.stub();
+      spyOn(component, 'randomString').and.returnValue(mockRandomString);
     });
-
-    it('should be creating request correctly', () => {
-      const image = 'nvme_ns_image:' + mockTimestamp;
+    it('should create 5 namespaces correctly', () => {
       component.onSubmit();
+      expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5);
       expect(nvmeofService.createNamespace).toHaveBeenCalledWith(mockSubsystemNQN, {
-        rbd_image_name: image,
-        rbd_pool: null,
+        gw_group: mockGWgroup,
+        rbd_image_name: `nvme_rbd_default_${mockRandomString}`,
+        rbd_pool: 'rbd',
         rbd_image_size: 1073741824
       });
     });
-
-    it('should give error on invalid image name', () => {
-      formHelper.setValue('image', '/ghfhdlk;kd;@');
-      component.onSubmit();
-      formHelper.expectError('image', 'pattern');
-    });
-
     it('should give error on invalid image size', () => {
       formHelper.setValue('image_size', -56);
       component.onSubmit();
       formHelper.expectError('image_size', 'pattern');
     });
-
     it('should give error on 0 image size', () => {
       formHelper.setValue('image_size', 0);
       component.onSubmit();
index 55d016f550ccc76d28b0b95a8d8874082711f021..8a2b05b9c7a11c56ceec4492a71f886f5349bd35 100644 (file)
@@ -18,9 +18,10 @@ import { Pool } from '../../pool/pool';
 import { PoolService } from '~/app/shared/api/pool.service';
 import { RbdService } from '~/app/shared/api/rbd.service';
 import { FormatterService } from '~/app/shared/services/formatter.service';
-import { Observable } from 'rxjs';
+import { forkJoin, Observable } from 'rxjs';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
 import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { HttpResponse } from '@angular/common/http';
 
 @Component({
   selector: 'cd-nvmeof-namespaces-form',
@@ -42,6 +43,10 @@ export class NvmeofNamespacesFormComponent implements OnInit {
   currentBytes: number;
   invalidSizeError: boolean;
   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`;
 
   constructor(
     public actionLabels: ActionLabelsI18n,
@@ -96,6 +101,9 @@ export class NvmeofNamespacesFormComponent implements OnInit {
     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);
+    }
   }
 
   ngOnInit() {
@@ -109,52 +117,52 @@ export class NvmeofNamespacesFormComponent implements OnInit {
 
   createForm() {
     this.nsForm = new CdFormGroup({
-      image: new UntypedFormControl(`nvme_ns_image:${Date.now()}`, {
-        validators: [Validators.required, Validators.pattern(/^[^@/]+?$/)]
-      }),
       pool: new UntypedFormControl(null, {
         validators: [Validators.required]
       }),
       image_size: new UntypedFormControl(1, [CdValidators.number(false), Validators.min(1)]),
-      unit: new UntypedFormControl(this.units[2])
+      unit: new UntypedFormControl(this.units[2]),
+      nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
+        Validators.required,
+        Validators.max(this.MAX_NAMESPACE_CREATE),
+        Validators.min(this.MIN_NAMESPACE_CREATE)
+      ])
     });
   }
 
-  updateRequest(rbdImageSize: number): NamespaceUpdateRequest {
+  buildUpdateRequest(rbdImageSize: number): Observable<HttpResponse<Object>> {
     const request: NamespaceUpdateRequest = {
       gw_group: this.group,
       rbd_image_size: rbdImageSize
     };
-    return request;
+    return this.nvmeofService.updateNamespace(
+      this.subsystemNQN,
+      this.nsid,
+      request as NamespaceUpdateRequest
+    );
   }
 
-  createRequest(rbdImageSize: number): NamespaceCreateRequest {
-    const image = this.nsForm.getValue('image');
-    const pool = this.nsForm.getValue('pool');
-    const request: NamespaceCreateRequest = {
-      gw_group: this.group,
-      rbd_image_name: image,
-      rbd_pool: pool
-    };
-    if (rbdImageSize) {
-      request['rbd_image_size'] = rbdImageSize;
-    }
-    return request;
+  randomString() {
+    return Math.random().toString(36).substring(2);
   }
 
-  buildRequest() {
-    const image_size = this.nsForm.getValue('image_size');
-    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) {
-      return this.updateRequest(rbdImageSize);
-    } else {
-      return this.createRequest(rbdImageSize);
+  buildCreateRequest(rbdImageSize: number, nsCount: number): Observable<HttpResponse<Object>>[] {
+    const pool = this.nsForm.getValue('pool');
+    const requests: Observable<HttpResponse<Object>>[] = [];
+
+    for (let i = 1; i <= nsCount; i++) {
+      const request: NamespaceCreateRequest = {
+        gw_group: this.group,
+        rbd_image_name: `nvme_${pool}_${this.group}_${this.randomString()}`,
+        rbd_pool: pool
+      };
+      if (rbdImageSize) {
+        request['rbd_image_size'] = rbdImageSize;
+      }
+      requests.push(this.nvmeofService.createNamespace(this.subsystemNQN, request));
     }
+
+    return requests;
   }
 
   validateSize() {
@@ -175,26 +183,31 @@ export class NvmeofNamespacesFormComponent implements OnInit {
       this.invalidSizeError = false;
       const component = this;
       const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
-      const request: NamespaceCreateRequest | NamespaceUpdateRequest = this.buildRequest();
-      let action: Observable<any>;
+      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.nvmeofService.updateNamespace(this.subsystemNQN, this.nsid, request)
+          call: this.buildUpdateRequest(rbdImageSize)
         });
       } else {
         action = this.taskWrapperService.wrapTaskAroundCall({
           task: new FinishedTask(taskUrl, {
-            nqn: this.subsystemNQN
+            nqn: this.subsystemNQN,
+            nsCount
           }),
-          call: this.nvmeofService.createNamespace(
-            this.subsystemNQN,
-            request as NamespaceCreateRequest
-          )
+          call: forkJoin(this.buildCreateRequest(rbdImageSize, nsCount))
         });
       }
 
index c4035ad28499d725477c5375ec724b9ce9b61e10..bcdb805ec82d4ffbb3c3e5303c86d439ef707596 100644 (file)
@@ -4,7 +4,10 @@ import { Pipe, PipeTransform } from '@angular/core';
   name: 'pluralize'
 })
 export class PluralizePipe implements PipeTransform {
-  transform(value: string): string {
+  transform(value: string, count?: number): string {
+    if (count <= 1) {
+      return value;
+    }
     if (value.endsWith('y')) {
       return value.slice(0, -1) + 'ies';
     } else {
index 8bf5a9bc16cb22d6c4f9143f3c6a61374a75dd84..852c4dfeefe21f119e39362b120214fbce3336c0 100644 (file)
@@ -5,6 +5,7 @@ import { Components } from '../enum/components.enum';
 import { FinishedTask } from '../models/finished-task';
 import { ImageSpec } from '../models/image-spec';
 import { Task } from '../models/task';
+import { PluralizePipe } from '../pipes/pluralize.pipe';
 
 export class TaskMessageOperation {
   running: string;
@@ -63,6 +64,8 @@ export class TaskMessageService {
     }
   );
 
+  pluralize = new PluralizePipe().transform;
+
   commonOperations = {
     create: new TaskMessageOperation($localize`Creating`, $localize`create`, $localize`Created`),
     update: new TaskMessageOperation($localize`Updating`, $localize`update`, $localize`Updated`),
@@ -537,15 +540,18 @@ export class TaskMessageService {
     return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
   }
 
-  nvmeofNamespace(metadata: any) {
+  nvmeofNamespace(metadata: { nqn: string; nsCount?: number; nsid?: string }) {
     if (metadata?.nsid) {
       return $localize`namespace ${metadata.nsid} for subsystem '${metadata.nqn}'`;
     }
-    return $localize`namespace for subsystem '${metadata.nqn}'`;
+    return $localize`${metadata.nsCount} ${this.pluralize(
+      'namespace',
+      metadata.nsCount
+    )} for subsystem '${metadata.nqn}'`;
   }
 
-  nvmeofInitiator(metadata: any) {
-    return $localize`initiator${metadata?.plural ? 's' : ''} for subsystem ${metadata.nqn}`;
+  nvmeofInitiator(metadata: { plural: number; nqn: string }) {
+    return $localize`${this.pluralize('initiator', metadata.plural)} for subsystem ${metadata.nqn}`;
   }
 
   nfs(metadata: any) {