From: Ville Ojamo Date: Tue, 3 Feb 2026 05:59:35 +0000 (+0700) Subject: mgr/dashboard: remove trailing space from directory name X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=608982ffe021988440b796c7253f31811983fc5b;p=ceph.git mgr/dashboard: remove trailing space from directory name 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> --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index f09a5fbf1a1..13bb5888296 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -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 index 9c498ec9da0..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html +++ /dev/null @@ -1,129 +0,0 @@ -
-
-
-
-

{{ action | titlecase }} {{ resource }}

- - - A logical group of gateways that hosts will connect to. - - - -
-
-
- - Gateway group name - - - This field is required. - Group name must be unique. - Special characters are not allowed. - -
-
- -
-
- - - - - - - This field is required. - -
-
- - -
-
-

Select target nodes

- - - Gateway nodes to run NVMe-oF target pods/services - - -
-
- -
-
- -
- - -
-
-
-
- - 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 index e69de29bb2d..00000000000 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 index f3fae1748a3..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts +++ /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; - 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 index f91d156d68b..00000000000 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts +++ /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 index 00000000000..9c498ec9da0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.html @@ -0,0 +1,129 @@ +
+
+
+
+

{{ action | titlecase }} {{ resource }}

+ + + A logical group of gateways that hosts will connect to. + + + +
+
+
+ + Gateway group name + + + This field is required. + Group name must be unique. + Special characters are not allowed. + +
+
+ +
+
+ + + + + + + This field is required. + +
+
+ + +
+
+

Select target nodes

+ + + Gateway nodes to run NVMe-oF target pods/services + + +
+
+ +
+
+ +
+ + +
+
+
+
+ + 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 index 00000000000..e69de29bb2d 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 index 00000000000..f3fae1748a3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts @@ -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; + 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 index 00000000000..f91d156d68b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts @@ -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'); + } +}