From b85f982def41320003ecb632e2b03a8779567fbe Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Sat, 22 Jun 2024 17:30:49 +0530 Subject: [PATCH] mgr/dashboard: Configure subsystems from dashboard Fixes https://tracker.ceph.com/issues/66659 - adds subsytems tab - adds subsystem listing view - adds create subsystem modal - adds delete subsystem - adds unit tests Signed-off-by: Afreen Misbah --- .../src/app/ceph/block/block.module.ts | 30 ++++- .../nvmeof-gateway.component.html | 10 +- .../nvmeof-gateway.component.ts | 9 +- .../nvmeof-subsystems-details.component.html | 17 +++ .../nvmeof-subsystems-details.component.scss | 0 ...vmeof-subsystems-details.component.spec.ts | 49 +++++++++ .../nvmeof-subsystems-details.component.ts | 27 +++++ .../nvmeof-subsystems-form.component.html | 74 +++++++++++++ .../nvmeof-subsystems-form.component.scss | 0 .../nvmeof-subsystems-form.component.spec.ts | 91 ++++++++++++++++ .../nvmeof-subsystems-form.component.ts | 102 +++++++++++++++++ .../nvmeof-subsystems.component.html | 29 +++++ .../nvmeof-subsystems.component.scss | 0 .../nvmeof-subsystems.component.spec.ts | 80 ++++++++++++++ .../nvmeof-subsystems.component.ts | 103 ++++++++++++++++++ .../nvmeof-tabs/nvmeof-tabs.component.html | 16 +++ .../nvmeof-tabs/nvmeof-tabs.component.scss | 0 .../nvmeof-tabs/nvmeof-tabs.component.spec.ts | 22 ++++ .../nvmeof-tabs/nvmeof-tabs.component.ts | 8 ++ .../src/app/shared/api/nvmeof.service.ts | 34 +++++- .../frontend/src/app/shared/models/nvmeof.ts | 11 ++ .../shared/services/task-message.service.ts | 11 ++ 22 files changed, 705 insertions(+), 18 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts 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 30a8d21d2c3f..8e926a40d99e 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 @@ -39,6 +39,10 @@ import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-mov import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component'; import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component'; import { NvmeofGatewayComponent } from './nvmeof-gateway/nvmeof-gateway.component'; +import { NvmeofSubsystemsComponent } from './nvmeof-subsystems/nvmeof-subsystems.component'; +import { NvmeofSubsystemsDetailsComponent } from './nvmeof-subsystems-details/nvmeof-subsystems-details.component'; +import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component'; +import { NvmeofSubsystemsFormComponent } from './nvmeof-subsystems-form/nvmeof-subsystems-form.component'; @NgModule({ imports: [ @@ -79,7 +83,11 @@ import { NvmeofGatewayComponent } from './nvmeof-gateway/nvmeof-gateway.componen RbdConfigurationFormComponent, RbdTabsComponent, RbdPerformanceComponent, - NvmeofGatewayComponent + NvmeofGatewayComponent, + NvmeofSubsystemsComponent, + NvmeofSubsystemsDetailsComponent, + NvmeofTabsComponent, + NvmeofSubsystemsFormComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] }) @@ -220,7 +228,25 @@ const routes: Routes = [ } }, children: [ - { path: '', redirectTo: 'gateways', pathMatch: 'full' }, + { path: '', redirectTo: 'subsystems', pathMatch: 'full' }, + { + path: 'subsystems', + component: NvmeofSubsystemsComponent, + data: { breadcrumbs: 'Subsystems' }, + children: [ + { path: '', component: NvmeofSubsystemsComponent }, + { + path: URLVerbs.CREATE, + component: NvmeofSubsystemsFormComponent, + outlet: 'modal' + }, + { + path: `${URLVerbs.EDIT}/:subsystem_nqn`, + component: NvmeofSubsystemsFormComponent, + outlet: 'modal' + } + ] + }, { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } } ] } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html index 3d27a77b1a84..18a4000e14de 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html @@ -1,12 +1,4 @@ - + Gateways diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts index 7d5b3fbe9fe9..46600388bd96 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts @@ -1,7 +1,6 @@ -import { Component, OnInit } from '@angular/core'; +import { Component } from '@angular/core'; import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; -import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { NvmeofGateway } from '~/app/shared/models/nvmeof'; @@ -12,14 +11,12 @@ import { NvmeofService } from '../../../shared/api/nvmeof.service'; templateUrl: './nvmeof-gateway.component.html', styleUrls: ['./nvmeof-gateway.component.scss'] }) -export class NvmeofGatewayComponent extends ListWithDetails implements OnInit { +export class NvmeofGatewayComponent { gateways: NvmeofGateway[] = []; gatewayColumns: any; selection = new CdTableSelection(); - constructor(private nvmeofService: NvmeofService, public actionLabels: ActionLabelsI18n) { - super(); - } + constructor(private nvmeofService: NvmeofService, public actionLabels: ActionLabelsI18n) {} ngOnInit() { this.gatewayColumns = [ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html new file mode 100644 index 000000000000..56a05dfecda5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html @@ -0,0 +1,17 @@ + + + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.spec.ts new file mode 100644 index 000000000000..80cdf927b9a7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.spec.ts @@ -0,0 +1,49 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofSubsystemsDetailsComponent } from './nvmeof-subsystems-details.component'; + +describe('NvmeofSubsystemsDetailsComponent', () => { + let component: NvmeofSubsystemsDetailsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofSubsystemsDetailsComponent], + imports: [BrowserAnimationsModule, SharedModule, HttpClientTestingModule, NgbNavModule] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofSubsystemsDetailsComponent); + component = fixture.componentInstance; + component.selection = { + serial_number: 'Ceph30487186726692', + model_number: 'Ceph bdev Controller', + min_cntlid: 1, + max_cntlid: 2040, + subtype: 'NVMe', + nqn: 'nqn.2001-07.com.ceph:1720603703820', + namespace_count: 1, + max_namespaces: 256 + }; + component.ngOnChanges(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should prepare data', () => { + expect(component.data).toEqual({ + 'Serial Number': 'Ceph30487186726692', + 'Model Number': 'Ceph bdev Controller', + 'Minimum Controller Identifier': 1, + 'Maximum Controller Identifier': 2040, + 'Subsystem Type': 'NVMe' + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts new file mode 100644 index 000000000000..a79b01d6704d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts @@ -0,0 +1,27 @@ +import { Component, Input, OnChanges } from '@angular/core'; +import { NvmeofSubsystem } from '~/app/shared/models/nvmeof'; + +@Component({ + selector: 'cd-nvmeof-subsystems-details', + templateUrl: './nvmeof-subsystems-details.component.html', + styleUrls: ['./nvmeof-subsystems-details.component.scss'] +}) +export class NvmeofSubsystemsDetailsComponent implements OnChanges { + @Input() + selection: NvmeofSubsystem; + + selectedItem: any; + data: any; + + ngOnChanges() { + if (this.selection) { + this.selectedItem = this.selection; + this.data = {}; + this.data[$localize`Serial Number`] = this.selectedItem.serial_number; + this.data[$localize`Model Number`] = this.selectedItem.model_number; + this.data[$localize`Minimum Controller Identifier`] = this.selectedItem.min_cntlid; + this.data[$localize`Maximum Controller Identifier`] = this.selectedItem.max_cntlid; + this.data[$localize`Subsystem Type`] = this.selectedItem.subtype; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html new file mode 100644 index 000000000000..93e27094e17f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html @@ -0,0 +1,74 @@ + + {{ action | titlecase }} {{ resource | upperFirst }} + +
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts new file mode 100644 index 000000000000..bfa642de1cb9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts @@ -0,0 +1,91 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ToastrModule } from 'ngx-toastr'; + +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 { NvmeofSubsystemsFormComponent } from './nvmeof-subsystems-form.component'; +import { FormHelper } from '~/testing/unit-test-helper'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +describe('NvmeofSubsystemsFormComponent', () => { + let component: NvmeofSubsystemsFormComponent; + let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + let form: CdFormGroup; + let formHelper: FormHelper; + const mockTimestamp = 1720693470789; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofSubsystemsFormComponent], + providers: [NgbActiveModal], + imports: [ + HttpClientTestingModule, + NgbTypeaheadModule, + ReactiveFormsModule, + RouterTestingModule, + SharedModule, + ToastrModule.forRoot() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofSubsystemsFormComponent); + component = fixture.componentInstance; + component.ngOnInit(); + form = component.subsystemForm; + formHelper = new FormHelper(form); + spyOn(Date, 'now').and.returnValue(mockTimestamp); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('should test form', () => { + beforeEach(() => { + nvmeofService = TestBed.inject(NvmeofService); + spyOn(nvmeofService, 'createSubsystem').and.stub(); + }); + + it('should be creating request correctly', () => { + const expectedNqn = 'nqn.2001-07.com.ceph:' + mockTimestamp; + component.onSubmit(); + expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({ + nqn: expectedNqn, + max_namespaces: 256, + enable_ha: true + }); + }); + + it('should give error on invalid nqn', () => { + formHelper.setValue('nqn', 'nqn:2001-07.com.ceph:'); + component.onSubmit(); + formHelper.expectError('nqn', 'pattern'); + }); + + it('should give error on invalid max_namespaces', () => { + formHelper.setValue('max_namespaces', -56); + component.onSubmit(); + formHelper.expectError('max_namespaces', 'pattern'); + }); + + it('should give error on max_namespaces greater than 256', () => { + formHelper.setValue('max_namespaces', 300); + component.onSubmit(); + formHelper.expectError('max_namespaces', 'max'); + }); + + it('should give error on max_namespaces lesser than 1', () => { + formHelper.setValue('max_namespaces', 0); + component.onSubmit(); + formHelper.expectError('max_namespaces', 'min'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts new file mode 100644 index 000000000000..2af218af54a0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts @@ -0,0 +1,102 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormControl, Validators } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +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 { FinishedTask } from '~/app/shared/models/finished-task'; +import { Router } from '@angular/router'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +@Component({ + selector: 'cd-nvmeof-subsystems-form', + templateUrl: './nvmeof-subsystems-form.component.html', + styleUrls: ['./nvmeof-subsystems-form.component.scss'] +}) +export class NvmeofSubsystemsFormComponent implements OnInit { + permission: Permission; + subsystemForm: CdFormGroup; + + action: string; + resource: string; + pageURL: string; + + NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+)$/; + + constructor( + private authStorageService: AuthStorageService, + public actionLabels: ActionLabelsI18n, + public activeModal: NgbActiveModal, + private nvmeofService: NvmeofService, + private taskWrapperService: TaskWrapperService, + private router: Router + ) { + this.permission = this.authStorageService.getPermissions().nvmeof; + this.resource = $localize`Subsystem`; + this.pageURL = 'block/nvmeof/subsystems'; + } + + ngOnInit() { + this.createForm(); + this.action = this.actionLabels.CREATE; + } + + createForm() { + this.subsystemForm = new CdFormGroup({ + nqn: new UntypedFormControl('nqn.2001-07.com.ceph:' + Date.now(), { + validators: [ + Validators.required, + Validators.pattern(this.NQN_REGEX), + CdValidators.custom( + 'maxLength', + (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223 + ) + ], + asyncValidators: [ + CdValidators.unique(this.nvmeofService.isSubsystemPresent, this.nvmeofService) + ] + }), + max_namespaces: new UntypedFormControl(256, { + validators: [CdValidators.number(false), Validators.max(256), Validators.min(1)] + }) + }); + } + + onSubmit() { + const component = this; + const nqn: string = this.subsystemForm.getValue('nqn'); + let max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces')); + + const request = { + nqn, + max_namespaces, + enable_ha: true + }; + + if (!max_namespaces) { + delete request.max_namespaces; + } + + let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`; + + this.taskWrapperService + .wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + nqn: nqn + }), + call: this.nvmeofService.createSubsystem(request) + }) + .subscribe({ + error() { + component.subsystemForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.router.navigate([this.pageURL, { outlets: { modal: null } }]); + } + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html new file mode 100644 index 000000000000..4dc044373302 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html @@ -0,0 +1,29 @@ + + + Subsystems + + A subsystem presents a collection of controllers which are used to access namespaces. + + + + +
+ + +
+ + + +
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts new file mode 100644 index 000000000000..1efd28dd1140 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts @@ -0,0 +1,80 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { HttpClientModule } from '@angular/common/http'; +import { of } from 'rxjs'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SharedModule } from '~/app/shared/shared.module'; + +import { NvmeofService } from '../../../shared/api/nvmeof.service'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component'; +import { NvmeofTabsComponent } from '../nvmeof-tabs/nvmeof-tabs.component'; +import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component'; + +const mockSubsystems = [ + { + nqn: 'nqn.2001-07.com.ceph:1720603703820', + enable_ha: true, + serial_number: 'Ceph30487186726692', + model_number: 'Ceph bdev Controller', + min_cntlid: 1, + max_cntlid: 2040, + namespace_count: 0, + subtype: 'NVMe', + max_namespaces: 256 + } +]; + +class MockNvmeOfService { + listSubsystems() { + return of(mockSubsystems); + } +} + +class MockAuthStorageService { + getPermissions() { + return { nvmeof: {} }; + } +} + +class MockModalService {} + +class MockTaskWrapperService {} + +describe('NvmeofSubsystemsComponent', () => { + let component: NvmeofSubsystemsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + NvmeofSubsystemsComponent, + NvmeofTabsComponent, + NvmeofSubsystemsDetailsComponent + ], + imports: [HttpClientModule, RouterTestingModule, SharedModule], + providers: [ + { provide: NvmeofService, useClass: MockNvmeOfService }, + { provide: AuthStorageService, useClass: MockAuthStorageService }, + { provide: ModalService, useClass: MockModalService }, + { provide: TaskWrapperService, useClass: MockTaskWrapperService } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofSubsystemsComponent); + component = fixture.componentInstance; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve subsystems', fakeAsync(() => { + component.getSubsystems(); + tick(); + expect(component.subsystems).toEqual(mockSubsystems); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts new file mode 100644 index 000000000000..d281901627b9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts @@ -0,0 +1,103 @@ +import { Component, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; + +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { NvmeofSubsystem } from '~/app/shared/models/nvmeof'; +import { Permission } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { ModalService } from '~/app/shared/services/modal.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +const BASE_URL = 'block/nvmeof/subsystems'; + +@Component({ + selector: 'cd-nvmeof-subsystems', + templateUrl: './nvmeof-subsystems.component.html', + styleUrls: ['./nvmeof-subsystems.component.scss'] +}) +export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit { + subsystems: NvmeofSubsystem[] = []; + subsystemsColumns: any; + permission: Permission; + selection = new CdTableSelection(); + tableActions: CdTableAction[]; + subsystemDetails: any[]; + + constructor( + private nvmeofService: NvmeofService, + private authStorageService: AuthStorageService, + public actionLabels: ActionLabelsI18n, + private router: Router, + private modalService: ModalService, + private taskWrapper: TaskWrapperService + ) { + super(); + this.permission = this.authStorageService.getPermissions().nvmeof; + } + + ngOnInit() { + this.subsystemsColumns = [ + { + name: $localize`NQN`, + prop: 'nqn' + }, + { + name: $localize`# Namespaces`, + prop: 'namespace_count' + }, + { + name: $localize`# Maximum Namespaces`, + prop: 'max_namespaces' + } + ]; + this.tableActions = [ + { + name: this.actionLabels.CREATE, + permission: 'create', + icon: Icons.add, + click: () => this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, + { + name: this.actionLabels.DELETE, + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteSubsystemModal() + } + ]; + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + getSubsystems() { + this.nvmeofService + .listSubsystems() + .subscribe((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => { + if (Array.isArray(subsystems)) this.subsystems = subsystems; + else this.subsystems = [subsystems]; + }); + } + + deleteSubsystemModal() { + const subsystem = this.selection.first(); + this.modalService.show(CriticalConfirmationModalComponent, { + itemDescription: 'Subsystem', + itemNames: [subsystem.nqn], + actionDescription: 'delete', + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }), + call: this.nvmeofService.deleteSubsystem(subsystem.nqn) + }) + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html new file mode 100644 index 000000000000..29f1e2ad6643 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html @@ -0,0 +1,16 @@ + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts new file mode 100644 index 000000000000..23e334a6e142 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { NvmeofTabsComponent } from './nvmeof-tabs.component'; + +describe('NvmeofTabsComponent', () => { + let component: NvmeofTabsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofTabsComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofTabsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts new file mode 100644 index 000000000000..507116c466ff --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts @@ -0,0 +1,8 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'cd-nvmeof-tabs', + templateUrl: './nvmeof-tabs.component.html', + styleUrls: ['./nvmeof-tabs.component.scss'] +}) +export class NvmeofTabsComponent {} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts index b1d4bace97de..8d5b8a3830c8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts @@ -1,5 +1,9 @@ -import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; +import { HttpClient } from '@angular/common/http'; + +import _ from 'lodash'; +import { Observable, of as observableOf } from 'rxjs'; +import { catchError, mapTo } from 'rxjs/operators'; const BASE_URL = 'api/nvmeof'; @@ -12,4 +16,32 @@ export class NvmeofService { listGateways() { return this.http.get(`${BASE_URL}/gateway`); } + + listSubsystems() { + return this.http.get(`${BASE_URL}/subsystem`); + } + + getSubsystem(subsystemNQN: string) { + return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}`); + } + + createSubsystem(request: { nqn: string; max_namespaces?: number; enable_ha: boolean }) { + return this.http.post(`${BASE_URL}/subsystem`, request, { observe: 'response' }); + } + + deleteSubsystem(subsystemNQN: string) { + return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}`, { + observe: 'response' + }); + } + + isSubsystemPresent(subsystemNqn: string): Observable { + return this.getSubsystem(subsystemNqn).pipe( + mapTo(true), + catchError((e) => { + e?.preventDefault(); + return observableOf(false); + }) + ); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts index f2a92ea9fb59..e383d4a1dfca 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts @@ -8,3 +8,14 @@ export interface NvmeofGateway { load_balancing_group: string; spdk_version: string; } + +export interface NvmeofSubsystem { + nqn: string; + serial_number: string; + model_number: string; + min_cntlid: number; + max_cntlid: number; + namespace_count: number; + subtype: string; + max_namespaces: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index 4197b4790e62..71621072783c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -344,6 +344,13 @@ export class TaskMessageService { 'iscsi/target/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.iscsiTarget(metadata) ), + // NVME/TCP tasks + 'nvmeof/subsystem/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.nvmeofSubsystem(metadata) + ), + 'nvmeof/subsystem/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.nvmeofSubsystem(metadata) + ), 'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) => this.nfs(metadata) ), @@ -469,6 +476,10 @@ export class TaskMessageService { return $localize`target '${metadata.target_iqn}'`; } + nvmeofSubsystem(metadata: any) { + return $localize`subsystem '${metadata.nqn}'`; + } + nfs(metadata: any) { return $localize`NFS '${metadata.cluster_id}\:${ metadata.export_id ? metadata.export_id : metadata.path -- 2.47.3