]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: remove trailing space from directory name 67174/head
authorVille Ojamo <ojamo.ville@an01.sabay.test>
Tue, 3 Feb 2026 05:59:35 +0000 (12:59 +0700)
committerVille Ojamo <ojamo.ville@an01.sabay.test>
Wed, 4 Feb 2026 05:01:15 +0000 (12:01 +0700)
Commit 6a0b00c introduced a new directory nvmeof-group-form that has a
trailing space. Remove the trailing space and update the new directory
name in the code.

Fixes: https://tracker.ceph.com/issues/74721
Signed-off-by: Ville Ojamo <14869000+bluikko@users.noreply.github.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts [new file with mode: 0644]

index f09a5fbf1a1248a338c6725aba2839063306c269..13bb5888296e73637c7a1cc6593e0691186cf772 100644 (file)
@@ -50,7 +50,7 @@ import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-i
 import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
 import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
 import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
-import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
+import { NvmeofGroupFormComponent } from './nvmeof-group-form/nvmeof-group-form.component';
 
 import {
   ButtonModule,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html
deleted file mode 100644 (file)
index 9c498ec..0000000
+++ /dev/null
@@ -1,129 +0,0 @@
-<form
-  #formDir="ngForm"
-  [formGroup]="groupForm"
-  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 }}</h3>
-      <cd-help-text>
-        <span i18n>
-           A logical group of gateways that hosts will connect to.
-        </span>
-      </cd-help-text>
-      <cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
-      </div>
-      <div cdsRow
-           class="form-item">
-        <div cdsCol>
-          <cds-text-label
-            labelInputID="name"
-            i18n
-            i18n-helperText
-            helperText="A unique name to identify this gateway group."
-            cdRequiredField="Gateway group name"
-            [invalid]="groupName.isInvalid">
-            Gateway group name
-            <input
-              cdsText
-              cdValidate
-              type="text"
-              id="groupName"
-              #groupName="cdValidate"
-              autofocus
-              formControlName="groupName"
-              [invalid]="groupName.isInvalid"
-            />
-          </cds-text-label>
-          <span
-            class="invalid-feedback"
-            *ngIf="groupForm.showError('groupName', formDir, 'required')"
-            i18n>This field is required.</span>
-          <span
-            class="invalid-feedback"
-            *ngIf="groupForm.get('groupName')?.hasError('notUnique') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
-            i18n>Group name must be unique.</span>
-          <span
-            class="invalid-feedback"
-            *ngIf="groupForm.get('groupName')?.hasError('invalidChars') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
-            i18n>Special characters are not allowed.</span>
-
-        </div>
-      </div>
-      <!-- Pool -->
-      <div cdsRow
-           class="form-item">
-        <div cdsCol>
-          <cds-select
-            label="Block pool"
-            helperText="An RBD application-enabled pool in which the gateway configuration can be managed."
-            labelInputID="pool"
-            id="pool"
-            formControlName="pool"
-            cdRequiredField="Block pool"
-            [invalid]="groupForm.controls.pool.invalid && groupForm.controls.pool.dirty"
-            [invalidText]="poolError"
-            i18n-label
-            i18n-helperText
-          >
-            <option *ngIf="poolsLoading"
-                    [ngValue]="null"
-                    i18n>Loading...</option>
-            <option *ngIf="!poolsLoading && pools.length === 0"
-                    [ngValue]="null"
-                    i18n>-- No block pools available --</option>
-            <option *ngFor="let pool of pools"
-                    [value]="pool.pool_name">{{ pool.pool_name }}</option>
-          </cds-select>
-          <ng-template #poolError>
-            <span class="invalid-feedback"
-                  *ngIf="groupForm.showError('pool', formDir, 'required')"
-                  i18n>This field is required.</span>
-          </ng-template>
-        </div>
-      </div>
-
-      <!-- Target Nodes Selection -->
-      <div
-        cdsRow
-        class="form-item"
-      >
-      <div cdsCol>
-        <h1 class="cds--type-heading-02">Select target nodes</h1>
-      <cd-help-text>
-        <span i18n>
-          Gateway nodes to run NVMe-oF target pods/services
-        </span>
-      </cd-help-text>
-      </div>
-      <div
-        cdsCol
-        class="cds-pt-3 cds-pb-3"
-      >
-        <cd-nvmeof-gateway-node
-          (hostsLoaded)="onHostsLoaded($event)"
-        ></cd-nvmeof-gateway-node>
-      </div>
-      </div>
-
-      <div cdsRow>
-        <cd-form-button-panel
-          (submitActionEvent)="onSubmit()"
-          [form]="groupForm"
-          [submitText]="(action | titlecase) + ' ' + (resource)"
-          [disabled]="isCreateDisabled"
-          wrappingClass="text-right form-button"
-        >
-        </cd-form-button-panel>
-      </div>
-    </div>
-  </div>
-</form>
-
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts
deleted file mode 100644 (file)
index f3fae17..0000000
+++ /dev/null
@@ -1,224 +0,0 @@
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ReactiveFormsModule } from '@angular/forms';
-import { RouterTestingModule } from '@angular/router/testing';
-import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
-import { Router } from '@angular/router';
-import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
-
-import { ToastrModule } from 'ngx-toastr';
-import { of } from 'rxjs';
-
-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 { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
-import { GridModule, InputModule, SelectModule } from 'carbon-components-angular';
-import { PoolService } from '~/app/shared/api/pool.service';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { CephServiceService } from '~/app/shared/api/ceph-service.service';
-import { FormHelper } from '~/testing/unit-test-helper';
-
-describe('NvmeofGroupFormComponent', () => {
-  let component: NvmeofGroupFormComponent;
-  let fixture: ComponentFixture<NvmeofGroupFormComponent>;
-  let form: CdFormGroup;
-  let formHelper: FormHelper;
-  let poolService: PoolService;
-  let taskWrapperService: TaskWrapperService;
-  let cephServiceService: CephServiceService;
-  let router: Router;
-
-  const mockPools = [
-    { pool_name: 'rbd', application_metadata: ['rbd'] },
-    { pool_name: 'rbd', application_metadata: ['rbd'] },
-    { pool_name: 'pool2', application_metadata: ['rgw'] }
-  ];
-
-  beforeEach(async () => {
-    await TestBed.configureTestingModule({
-      declarations: [NvmeofGroupFormComponent],
-      providers: [NgbActiveModal],
-      imports: [
-        HttpClientTestingModule,
-        NgbTypeaheadModule,
-        ReactiveFormsModule,
-        RouterTestingModule,
-        SharedModule,
-        GridModule,
-        InputModule,
-        SelectModule,
-        ToastrModule.forRoot()
-      ],
-      schemas: [CUSTOM_ELEMENTS_SCHEMA]
-    }).compileComponents();
-
-    fixture = TestBed.createComponent(NvmeofGroupFormComponent);
-    component = fixture.componentInstance;
-    poolService = TestBed.inject(PoolService);
-    taskWrapperService = TestBed.inject(TaskWrapperService);
-    cephServiceService = TestBed.inject(CephServiceService);
-    router = TestBed.inject(Router);
-
-    spyOn(poolService, 'list').and.returnValue(Promise.resolve(mockPools));
-
-    component.ngOnInit();
-    form = component.groupForm;
-    formHelper = new FormHelper(form);
-    fixture.detectChanges();
-  });
-
-  it('should create', () => {
-    expect(component).toBeTruthy();
-  });
-
-  it('should initialize form with empty fields', () => {
-    expect(form.controls.groupName.value).toBeNull();
-    expect(form.controls.unmanaged.value).toBe(false);
-  });
-
-  it('should set action to CREATE on init', () => {
-    expect(component.action).toBe('Create');
-  });
-
-  it('should set resource to gateway group', () => {
-    expect(component.resource).toBe('gateway group');
-  });
-
-  describe('form validation', () => {
-    it('should require groupName', () => {
-      formHelper.setValue('groupName', '');
-      formHelper.expectError('groupName', 'required');
-    });
-
-    it('should require pool', () => {
-      formHelper.setValue('pool', null);
-      formHelper.expectError('pool', 'required');
-    });
-
-    it('should be valid when groupName and pool are set', () => {
-      formHelper.setValue('groupName', 'test-group');
-      formHelper.setValue('pool', 'rbd');
-      expect(form.valid).toBe(true);
-    });
-  });
-
-  describe('loadPools', () => {
-    it('should load pools and filter by rbd application metadata', fakeAsync(() => {
-      component.loadPools();
-      tick();
-      expect(component.pools.length).toBe(2);
-      expect(component.pools.map((p) => p.pool_name)).toEqual(['rbd', 'rbd']);
-    }));
-
-    it('should set default pool to rbd if available', fakeAsync(() => {
-      component.groupForm.get('pool').setValue(null);
-      component.loadPools();
-      tick();
-      expect(component.groupForm.get('pool').value).toBe('rbd');
-    }));
-
-    it('should set first pool if rbd is not available', fakeAsync(() => {
-      component.groupForm.get('pool').setValue(null);
-      const poolsWithoutRbd = [{ pool_name: 'custom-pool', application_metadata: ['rbd'] }];
-      (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve(poolsWithoutRbd));
-      component.loadPools();
-      tick();
-      expect(component.groupForm.get('pool').value).toBe('custom-pool');
-    }));
-
-    it('should handle empty pools', fakeAsync(() => {
-      (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve([]));
-      component.loadPools();
-      tick();
-      expect(component.pools.length).toBe(0);
-      expect(component.poolsLoading).toBe(false);
-    }));
-
-    it('should handle pool loading error', fakeAsync(() => {
-      (poolService.list as jasmine.Spy).and.returnValue(Promise.reject('error'));
-      component.loadPools();
-      tick();
-      expect(component.pools).toEqual([]);
-      expect(component.poolsLoading).toBe(false);
-    }));
-  });
-
-  describe('onSubmit', () => {
-    beforeEach(() => {
-      spyOn(cephServiceService, 'create').and.returnValue(of({}));
-      spyOn(taskWrapperService, 'wrapTaskAroundCall').and.callFake(({ call }) => call);
-      spyOn(router, 'navigateByUrl');
-    });
-
-    it('should not call create if no hosts are selected', () => {
-      component.gatewayNodeComponent = {
-        getSelectedHosts: (): any[] => [],
-        getSelectedHostnames: (): string[] => []
-      } as any;
-
-      component.groupForm.get('groupName').setValue('test-group');
-      component.groupForm.get('pool').setValue('rbd');
-      component.onSubmit();
-
-      expect(cephServiceService.create).not.toHaveBeenCalled();
-    });
-
-    it('should create service with correct spec', () => {
-      component.gatewayNodeComponent = {
-        getSelectedHosts: (): any[] => [{ hostname: 'host1' }, { hostname: 'host2' }],
-        getSelectedHostnames: (): string[] => ['host1', 'host2']
-      } as any;
-
-      component.groupForm.get('groupName').setValue('defalut');
-      component.groupForm.get('pool').setValue('rbd');
-      component.groupForm.get('unmanaged').setValue(false);
-      component.onSubmit();
-
-      expect(cephServiceService.create).toHaveBeenCalledWith({
-        service_type: 'nvmeof',
-        service_id: 'rbd.defalut',
-        pool: 'rbd',
-        group: 'defalut',
-        placement: {
-          hosts: ['host1', 'host2']
-        },
-        unmanaged: false
-      });
-    });
-
-    it('should create service with unmanaged flag set to true', () => {
-      component.gatewayNodeComponent = {
-        getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
-        getSelectedHostnames: (): string[] => ['host1']
-      } as any;
-
-      component.groupForm.get('groupName').setValue('unmanaged-group');
-      component.groupForm.get('pool').setValue('rbd');
-      component.groupForm.get('unmanaged').setValue(true);
-      component.onSubmit();
-
-      expect(cephServiceService.create).toHaveBeenCalledWith(
-        jasmine.objectContaining({
-          unmanaged: true,
-          group: 'unmanaged-group',
-          pool: 'rbd'
-        })
-      );
-    });
-
-    it('should navigate to list view on success', () => {
-      component.gatewayNodeComponent = {
-        getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
-        getSelectedHostnames: (): string[] => ['host1']
-      } as any;
-
-      component.groupForm.get('groupName').setValue('test-group');
-      component.groupForm.get('pool').setValue('rbd');
-      component.onSubmit();
-
-      expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
-    });
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts
deleted file mode 100644 (file)
index f91d156..0000000
+++ /dev/null
@@ -1,180 +0,0 @@
-import { Component, OnInit, ViewChild } from '@angular/core';
-import { UntypedFormControl, Validators } from '@angular/forms';
-import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
-import { CdForm } from '~/app/shared/forms/cd-form';
-import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-
-import { Permission } from '~/app/shared/models/permissions';
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { PoolService } from '~/app/shared/api/pool.service';
-import { Pool } from '../../pool/pool';
-import { NvmeofGatewayNodeComponent } from '../nvmeof-gateway-node/nvmeof-gateway-node.component';
-import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { CephServiceService } from '~/app/shared/api/ceph-service.service';
-import { FinishedTask } from '~/app/shared/models/finished-task';
-import { Router } from '@angular/router';
-import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-
-@Component({
-  selector: 'cd-nvmeof-group-form',
-  templateUrl: './nvmeof-group-form.component.html',
-  styleUrls: ['./nvmeof-group-form.component.scss'],
-  standalone: false
-})
-export class NvmeofGroupFormComponent extends CdForm implements OnInit {
-  @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
-
-  permission: Permission;
-  groupForm: CdFormGroup;
-  action: string;
-  resource: string;
-  group: string;
-  pools: Pool[] = [];
-  poolsLoading = false;
-  pageURL: string;
-  hasAvailableNodes = true;
-
-  constructor(
-    private authStorageService: AuthStorageService,
-    public actionLabels: ActionLabelsI18n,
-    private poolService: PoolService,
-    private taskWrapperService: TaskWrapperService,
-    private cephServiceService: CephServiceService,
-    private nvmeofService: NvmeofService,
-    private router: Router
-  ) {
-    super();
-    this.permission = this.authStorageService.getPermissions().nvmeof;
-    this.resource = $localize`gateway group`;
-  }
-
-  ngOnInit() {
-    this.action = this.actionLabels.CREATE;
-    this.createForm();
-    this.loadPools();
-  }
-
-  createForm() {
-    this.groupForm = new CdFormGroup({
-      groupName: new UntypedFormControl(
-        null,
-        [
-          Validators.required,
-          (control) => {
-            const value = control.value;
-            return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
-          }
-        ],
-        [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
-      ),
-      pool: new UntypedFormControl('rbd', {
-        validators: [Validators.required]
-      }),
-      unmanaged: new UntypedFormControl(false)
-    });
-  }
-
-  onHostsLoaded(count: number): void {
-    this.hasAvailableNodes = count > 0;
-  }
-
-  get isCreateDisabled(): boolean {
-    if (!this.hasAvailableNodes) {
-      return true;
-    }
-    if (!this.groupForm) {
-      return true;
-    }
-    if (this.groupForm.pending) {
-      return true;
-    }
-    if (this.groupForm.invalid) {
-      return true;
-    }
-    const errors = this.groupForm.errors as { [key: string]: any } | null;
-    if (errors && errors.cdSubmitButton) {
-      return true;
-    }
-    if (this.gatewayNodeComponent) {
-      const selected = this.gatewayNodeComponent.getSelectedHostnames?.() || [];
-      if (selected.length === 0) {
-        return true;
-      }
-    }
-
-    return false;
-  }
-
-  loadPools() {
-    this.poolsLoading = true;
-    this.poolService.list().then(
-      (pools: Pool[]) => {
-        this.pools = (pools || []).filter(
-          (pool: Pool) => pool.application_metadata && pool.application_metadata.includes('rbd')
-        );
-        this.poolsLoading = false;
-        if (this.pools.length >= 1) {
-          const allPoolNames = this.pools.map((pool) => pool.pool_name);
-          const poolName = allPoolNames.includes('rbd') ? 'rbd' : this.pools[0].pool_name;
-          this.groupForm.patchValue({ pool: poolName });
-        }
-      },
-      () => {
-        this.pools = [];
-        this.poolsLoading = false;
-      }
-    );
-  }
-
-  onSubmit() {
-    if (this.groupForm.invalid) {
-      return;
-    }
-
-    if (this.groupForm.pending) {
-      this.groupForm.setErrors({ cdSubmitButton: true });
-      return;
-    }
-
-    const formValues = this.groupForm.value;
-    const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
-    if (selectedHostnames.length === 0) {
-      this.groupForm.setErrors({ cdSubmitButton: true });
-      return;
-    }
-    let taskUrl = `service/${URLVerbs.CREATE}`;
-    const serviceName = `${formValues.pool}.${formValues.groupName}`;
-
-    const serviceSpec = {
-      service_type: 'nvmeof',
-      service_id: serviceName,
-      pool: formValues.pool,
-      group: formValues.groupName,
-      placement: {
-        hosts: selectedHostnames
-      },
-      unmanaged: formValues.unmanaged
-    };
-
-    this.taskWrapperService
-      .wrapTaskAroundCall({
-        task: new FinishedTask(taskUrl, {
-          service_name: `nvmeof.${serviceName}`
-        }),
-        call: this.cephServiceService.create(serviceSpec)
-      })
-      .subscribe({
-        complete: () => {
-          this.goToListView();
-        },
-        error: () => {
-          this.groupForm.setErrors({ cdSubmitButton: true });
-        }
-      });
-  }
-
-  private goToListView() {
-    this.router.navigateByUrl('/block/nvmeof/gateways');
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html
new file mode 100644 (file)
index 0000000..9c498ec
--- /dev/null
@@ -0,0 +1,129 @@
+<form
+  #formDir="ngForm"
+  [formGroup]="groupForm"
+  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 }}</h3>
+      <cd-help-text>
+        <span i18n>
+           A logical group of gateways that hosts will connect to.
+        </span>
+      </cd-help-text>
+      <cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
+      </div>
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-text-label
+            labelInputID="name"
+            i18n
+            i18n-helperText
+            helperText="A unique name to identify this gateway group."
+            cdRequiredField="Gateway group name"
+            [invalid]="groupName.isInvalid">
+            Gateway group name
+            <input
+              cdsText
+              cdValidate
+              type="text"
+              id="groupName"
+              #groupName="cdValidate"
+              autofocus
+              formControlName="groupName"
+              [invalid]="groupName.isInvalid"
+            />
+          </cds-text-label>
+          <span
+            class="invalid-feedback"
+            *ngIf="groupForm.showError('groupName', formDir, 'required')"
+            i18n>This field is required.</span>
+          <span
+            class="invalid-feedback"
+            *ngIf="groupForm.get('groupName')?.hasError('notUnique') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+            i18n>Group name must be unique.</span>
+          <span
+            class="invalid-feedback"
+            *ngIf="groupForm.get('groupName')?.hasError('invalidChars') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+            i18n>Special characters are not allowed.</span>
+
+        </div>
+      </div>
+      <!-- Pool -->
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-select
+            label="Block pool"
+            helperText="An RBD application-enabled pool in which the gateway configuration can be managed."
+            labelInputID="pool"
+            id="pool"
+            formControlName="pool"
+            cdRequiredField="Block pool"
+            [invalid]="groupForm.controls.pool.invalid && groupForm.controls.pool.dirty"
+            [invalidText]="poolError"
+            i18n-label
+            i18n-helperText
+          >
+            <option *ngIf="poolsLoading"
+                    [ngValue]="null"
+                    i18n>Loading...</option>
+            <option *ngIf="!poolsLoading && pools.length === 0"
+                    [ngValue]="null"
+                    i18n>-- No block pools available --</option>
+            <option *ngFor="let pool of pools"
+                    [value]="pool.pool_name">{{ pool.pool_name }}</option>
+          </cds-select>
+          <ng-template #poolError>
+            <span class="invalid-feedback"
+                  *ngIf="groupForm.showError('pool', formDir, 'required')"
+                  i18n>This field is required.</span>
+          </ng-template>
+        </div>
+      </div>
+
+      <!-- Target Nodes Selection -->
+      <div
+        cdsRow
+        class="form-item"
+      >
+      <div cdsCol>
+        <h1 class="cds--type-heading-02">Select target nodes</h1>
+      <cd-help-text>
+        <span i18n>
+          Gateway nodes to run NVMe-oF target pods/services
+        </span>
+      </cd-help-text>
+      </div>
+      <div
+        cdsCol
+        class="cds-pt-3 cds-pb-3"
+      >
+        <cd-nvmeof-gateway-node
+          (hostsLoaded)="onHostsLoaded($event)"
+        ></cd-nvmeof-gateway-node>
+      </div>
+      </div>
+
+      <div cdsRow>
+        <cd-form-button-panel
+          (submitActionEvent)="onSubmit()"
+          [form]="groupForm"
+          [submitText]="(action | titlecase) + ' ' + (resource)"
+          [disabled]="isCreateDisabled"
+          wrappingClass="text-right form-button"
+        >
+        </cd-form-button-panel>
+      </div>
+    </div>
+  </div>
+</form>
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts
new file mode 100644 (file)
index 0000000..f3fae17
--- /dev/null
@@ -0,0 +1,224 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+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 { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
+import { GridModule, InputModule, SelectModule } from 'carbon-components-angular';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FormHelper } from '~/testing/unit-test-helper';
+
+describe('NvmeofGroupFormComponent', () => {
+  let component: NvmeofGroupFormComponent;
+  let fixture: ComponentFixture<NvmeofGroupFormComponent>;
+  let form: CdFormGroup;
+  let formHelper: FormHelper;
+  let poolService: PoolService;
+  let taskWrapperService: TaskWrapperService;
+  let cephServiceService: CephServiceService;
+  let router: Router;
+
+  const mockPools = [
+    { pool_name: 'rbd', application_metadata: ['rbd'] },
+    { pool_name: 'rbd', application_metadata: ['rbd'] },
+    { pool_name: 'pool2', application_metadata: ['rgw'] }
+  ];
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofGroupFormComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgbTypeaheadModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule,
+        GridModule,
+        InputModule,
+        SelectModule,
+        ToastrModule.forRoot()
+      ],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofGroupFormComponent);
+    component = fixture.componentInstance;
+    poolService = TestBed.inject(PoolService);
+    taskWrapperService = TestBed.inject(TaskWrapperService);
+    cephServiceService = TestBed.inject(CephServiceService);
+    router = TestBed.inject(Router);
+
+    spyOn(poolService, 'list').and.returnValue(Promise.resolve(mockPools));
+
+    component.ngOnInit();
+    form = component.groupForm;
+    formHelper = new FormHelper(form);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should initialize form with empty fields', () => {
+    expect(form.controls.groupName.value).toBeNull();
+    expect(form.controls.unmanaged.value).toBe(false);
+  });
+
+  it('should set action to CREATE on init', () => {
+    expect(component.action).toBe('Create');
+  });
+
+  it('should set resource to gateway group', () => {
+    expect(component.resource).toBe('gateway group');
+  });
+
+  describe('form validation', () => {
+    it('should require groupName', () => {
+      formHelper.setValue('groupName', '');
+      formHelper.expectError('groupName', 'required');
+    });
+
+    it('should require pool', () => {
+      formHelper.setValue('pool', null);
+      formHelper.expectError('pool', 'required');
+    });
+
+    it('should be valid when groupName and pool are set', () => {
+      formHelper.setValue('groupName', 'test-group');
+      formHelper.setValue('pool', 'rbd');
+      expect(form.valid).toBe(true);
+    });
+  });
+
+  describe('loadPools', () => {
+    it('should load pools and filter by rbd application metadata', fakeAsync(() => {
+      component.loadPools();
+      tick();
+      expect(component.pools.length).toBe(2);
+      expect(component.pools.map((p) => p.pool_name)).toEqual(['rbd', 'rbd']);
+    }));
+
+    it('should set default pool to rbd if available', fakeAsync(() => {
+      component.groupForm.get('pool').setValue(null);
+      component.loadPools();
+      tick();
+      expect(component.groupForm.get('pool').value).toBe('rbd');
+    }));
+
+    it('should set first pool if rbd is not available', fakeAsync(() => {
+      component.groupForm.get('pool').setValue(null);
+      const poolsWithoutRbd = [{ pool_name: 'custom-pool', application_metadata: ['rbd'] }];
+      (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve(poolsWithoutRbd));
+      component.loadPools();
+      tick();
+      expect(component.groupForm.get('pool').value).toBe('custom-pool');
+    }));
+
+    it('should handle empty pools', fakeAsync(() => {
+      (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve([]));
+      component.loadPools();
+      tick();
+      expect(component.pools.length).toBe(0);
+      expect(component.poolsLoading).toBe(false);
+    }));
+
+    it('should handle pool loading error', fakeAsync(() => {
+      (poolService.list as jasmine.Spy).and.returnValue(Promise.reject('error'));
+      component.loadPools();
+      tick();
+      expect(component.pools).toEqual([]);
+      expect(component.poolsLoading).toBe(false);
+    }));
+  });
+
+  describe('onSubmit', () => {
+    beforeEach(() => {
+      spyOn(cephServiceService, 'create').and.returnValue(of({}));
+      spyOn(taskWrapperService, 'wrapTaskAroundCall').and.callFake(({ call }) => call);
+      spyOn(router, 'navigateByUrl');
+    });
+
+    it('should not call create if no hosts are selected', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [],
+        getSelectedHostnames: (): string[] => []
+      } as any;
+
+      component.groupForm.get('groupName').setValue('test-group');
+      component.groupForm.get('pool').setValue('rbd');
+      component.onSubmit();
+
+      expect(cephServiceService.create).not.toHaveBeenCalled();
+    });
+
+    it('should create service with correct spec', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [{ hostname: 'host1' }, { hostname: 'host2' }],
+        getSelectedHostnames: (): string[] => ['host1', 'host2']
+      } as any;
+
+      component.groupForm.get('groupName').setValue('defalut');
+      component.groupForm.get('pool').setValue('rbd');
+      component.groupForm.get('unmanaged').setValue(false);
+      component.onSubmit();
+
+      expect(cephServiceService.create).toHaveBeenCalledWith({
+        service_type: 'nvmeof',
+        service_id: 'rbd.defalut',
+        pool: 'rbd',
+        group: 'defalut',
+        placement: {
+          hosts: ['host1', 'host2']
+        },
+        unmanaged: false
+      });
+    });
+
+    it('should create service with unmanaged flag set to true', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+        getSelectedHostnames: (): string[] => ['host1']
+      } as any;
+
+      component.groupForm.get('groupName').setValue('unmanaged-group');
+      component.groupForm.get('pool').setValue('rbd');
+      component.groupForm.get('unmanaged').setValue(true);
+      component.onSubmit();
+
+      expect(cephServiceService.create).toHaveBeenCalledWith(
+        jasmine.objectContaining({
+          unmanaged: true,
+          group: 'unmanaged-group',
+          pool: 'rbd'
+        })
+      );
+    });
+
+    it('should navigate to list view on success', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+        getSelectedHostnames: (): string[] => ['host1']
+      } as any;
+
+      component.groupForm.get('groupName').setValue('test-group');
+      component.groupForm.get('pool').setValue('rbd');
+      component.onSubmit();
+
+      expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts
new file mode 100644 (file)
index 0000000..f91d156
--- /dev/null
@@ -0,0 +1,180 @@
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { Pool } from '../../pool/pool';
+import { NvmeofGatewayNodeComponent } from '../nvmeof-gateway-node/nvmeof-gateway-node.component';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Router } from '@angular/router';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+@Component({
+  selector: 'cd-nvmeof-group-form',
+  templateUrl: './nvmeof-group-form.component.html',
+  styleUrls: ['./nvmeof-group-form.component.scss'],
+  standalone: false
+})
+export class NvmeofGroupFormComponent extends CdForm implements OnInit {
+  @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
+
+  permission: Permission;
+  groupForm: CdFormGroup;
+  action: string;
+  resource: string;
+  group: string;
+  pools: Pool[] = [];
+  poolsLoading = false;
+  pageURL: string;
+  hasAvailableNodes = true;
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    public actionLabels: ActionLabelsI18n,
+    private poolService: PoolService,
+    private taskWrapperService: TaskWrapperService,
+    private cephServiceService: CephServiceService,
+    private nvmeofService: NvmeofService,
+    private router: Router
+  ) {
+    super();
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+    this.resource = $localize`gateway group`;
+  }
+
+  ngOnInit() {
+    this.action = this.actionLabels.CREATE;
+    this.createForm();
+    this.loadPools();
+  }
+
+  createForm() {
+    this.groupForm = new CdFormGroup({
+      groupName: new UntypedFormControl(
+        null,
+        [
+          Validators.required,
+          (control) => {
+            const value = control.value;
+            return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
+          }
+        ],
+        [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
+      ),
+      pool: new UntypedFormControl('rbd', {
+        validators: [Validators.required]
+      }),
+      unmanaged: new UntypedFormControl(false)
+    });
+  }
+
+  onHostsLoaded(count: number): void {
+    this.hasAvailableNodes = count > 0;
+  }
+
+  get isCreateDisabled(): boolean {
+    if (!this.hasAvailableNodes) {
+      return true;
+    }
+    if (!this.groupForm) {
+      return true;
+    }
+    if (this.groupForm.pending) {
+      return true;
+    }
+    if (this.groupForm.invalid) {
+      return true;
+    }
+    const errors = this.groupForm.errors as { [key: string]: any } | null;
+    if (errors && errors.cdSubmitButton) {
+      return true;
+    }
+    if (this.gatewayNodeComponent) {
+      const selected = this.gatewayNodeComponent.getSelectedHostnames?.() || [];
+      if (selected.length === 0) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  loadPools() {
+    this.poolsLoading = true;
+    this.poolService.list().then(
+      (pools: Pool[]) => {
+        this.pools = (pools || []).filter(
+          (pool: Pool) => pool.application_metadata && pool.application_metadata.includes('rbd')
+        );
+        this.poolsLoading = false;
+        if (this.pools.length >= 1) {
+          const allPoolNames = this.pools.map((pool) => pool.pool_name);
+          const poolName = allPoolNames.includes('rbd') ? 'rbd' : this.pools[0].pool_name;
+          this.groupForm.patchValue({ pool: poolName });
+        }
+      },
+      () => {
+        this.pools = [];
+        this.poolsLoading = false;
+      }
+    );
+  }
+
+  onSubmit() {
+    if (this.groupForm.invalid) {
+      return;
+    }
+
+    if (this.groupForm.pending) {
+      this.groupForm.setErrors({ cdSubmitButton: true });
+      return;
+    }
+
+    const formValues = this.groupForm.value;
+    const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
+    if (selectedHostnames.length === 0) {
+      this.groupForm.setErrors({ cdSubmitButton: true });
+      return;
+    }
+    let taskUrl = `service/${URLVerbs.CREATE}`;
+    const serviceName = `${formValues.pool}.${formValues.groupName}`;
+
+    const serviceSpec = {
+      service_type: 'nvmeof',
+      service_id: serviceName,
+      pool: formValues.pool,
+      group: formValues.groupName,
+      placement: {
+        hosts: selectedHostnames
+      },
+      unmanaged: formValues.unmanaged
+    };
+
+    this.taskWrapperService
+      .wrapTaskAroundCall({
+        task: new FinishedTask(taskUrl, {
+          service_name: `nvmeof.${serviceName}`
+        }),
+        call: this.cephServiceService.create(serviceSpec)
+      })
+      .subscribe({
+        complete: () => {
+          this.goToListView();
+        },
+        error: () => {
+          this.groupForm.setErrors({ cdSubmitButton: true });
+        }
+      });
+  }
+
+  private goToListView() {
+    this.router.navigateByUrl('/block/nvmeof/gateways');
+  }
+}