From b45eb5579611b56ee033410a4688984ff468fdcd Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Tue, 16 Jul 2024 12:33:26 +0530 Subject: [PATCH] mgr/dashboard: Add namespaces views in dashboard Fixes https://tracker.ceph.com/issues/66990 - list namespaces - create namespaces - edit namespaces - delete namespaces - update NQN regex Signed-off-by: Afreen Misbah --- .../src/app/ceph/block/block.module.ts | 22 +- .../nvmeof-namespaces-form.component.html | 118 +++++++++++ .../nvmeof-namespaces-form.component.scss | 0 .../nvmeof-namespaces-form.component.spec.ts | 88 ++++++++ .../nvmeof-namespaces-form.component.ts | 192 ++++++++++++++++++ .../nvmeof-namespaces-list.component.html | 20 ++ .../nvmeof-namespaces-list.component.scss | 0 .../nvmeof-namespaces-list.component.spec.ts | 84 ++++++++ .../nvmeof-namespaces-list.component.ts | 181 +++++++++++++++++ .../nvmeof-subsystems-details.component.html | 7 + .../nvmeof-subsystems-form.component.html | 4 +- .../nvmeof-subsystems-form.component.spec.ts | 2 +- .../nvmeof-subsystems-form.component.ts | 15 +- .../nvmeof-subsystems.component.ts | 2 +- .../src/app/shared/api/nvmeof.service.spec.ts | 33 +++ .../src/app/shared/api/nvmeof.service.ts | 56 ++++- .../frontend/src/app/shared/models/nvmeof.ts | 19 ++ .../app/shared/pipes/mbpersecond.pipe.spec.ts | 8 + .../src/app/shared/pipes/mbpersecond.pipe.ts | 10 + .../src/app/shared/pipes/pipes.module.ts | 10 +- .../shared/services/task-message.service.ts | 19 ++ 21 files changed, 876 insertions(+), 14 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.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 21a0d5483ec..dec04b46387 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 @@ -45,6 +45,8 @@ import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component'; import { NvmeofSubsystemsFormComponent } from './nvmeof-subsystems-form/nvmeof-subsystems-form.component'; import { NvmeofListenersFormComponent } from './nvmeof-listeners-form/nvmeof-listeners-form.component'; import { NvmeofListenersListComponent } from './nvmeof-listeners-list/nvmeof-listeners-list.component'; +import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list/nvmeof-namespaces-list.component'; +import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form/nvmeof-namespaces-form.component'; @NgModule({ imports: [ @@ -91,7 +93,9 @@ import { NvmeofListenersListComponent } from './nvmeof-listeners-list/nvmeof-lis NvmeofTabsComponent, NvmeofSubsystemsFormComponent, NvmeofListenersFormComponent, - NvmeofListenersListComponent + NvmeofListenersListComponent, + NvmeofNamespacesListComponent, + NvmeofNamespacesFormComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] }) @@ -238,6 +242,7 @@ const routes: Routes = [ component: NvmeofSubsystemsComponent, data: { breadcrumbs: 'Subsystems' }, children: [ + // subsystems { path: '', component: NvmeofSubsystemsComponent }, { path: URLVerbs.CREATE, @@ -245,13 +250,24 @@ const routes: Routes = [ outlet: 'modal' }, { - path: `${URLVerbs.EDIT}/:subsystem_nqn`, + path: `${URLVerbs.EDIT}/:subsystem_nqn/:max_ns`, component: NvmeofSubsystemsFormComponent, outlet: 'modal' }, + // listeners { path: `${URLVerbs.CREATE}/:subsystem_nqn/listener`, - component: NvmeofListenersFormComponent, + component: NvmeofListenersFormComponent + }, + // namespaces + { + path: `${URLVerbs.CREATE}/:subsystem_nqn/namespace`, + component: NvmeofNamespacesFormComponent, + outlet: 'modal' + }, + { + path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`, + component: NvmeofNamespacesFormComponent, outlet: 'modal' } ] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html new file mode 100644 index 00000000000..f1e222bfd71 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html @@ -0,0 +1,118 @@ + + {{ action | titlecase }} {{ resource | upperFirst }} + +
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts new file mode 100644 index 00000000000..b6d0c27a70c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts @@ -0,0 +1,88 @@ +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 { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form.component'; +import { FormHelper } from '~/testing/unit-test-helper'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +describe('NvmeofNamespacesFormComponent', () => { + let component: NvmeofNamespacesFormComponent; + let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + let form: CdFormGroup; + let formHelper: FormHelper; + const mockTimestamp = 1720693470789; + const mockSubsystemNQN = 'nqn.2021-11.com.example:subsystem'; + + beforeEach(async () => { + spyOn(Date, 'now').and.returnValue(mockTimestamp); + await TestBed.configureTestingModule({ + declarations: [NvmeofNamespacesFormComponent], + providers: [NgbActiveModal], + imports: [ + HttpClientTestingModule, + NgbTypeaheadModule, + ReactiveFormsModule, + RouterTestingModule, + SharedModule, + ToastrModule.forRoot() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofNamespacesFormComponent); + component = fixture.componentInstance; + component.ngOnInit(); + form = component.nsForm; + formHelper = new FormHelper(form); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('should test form', () => { + beforeEach(() => { + component.subsystemNQN = mockSubsystemNQN; + nvmeofService = TestBed.inject(NvmeofService); + spyOn(nvmeofService, 'createNamespace').and.stub(); + }); + + it('should be creating request correctly', () => { + const image = 'nvme_ns_image:' + mockTimestamp; + component.onSubmit(); + expect(nvmeofService.createNamespace).toHaveBeenCalledWith(mockSubsystemNQN, { + rbd_image_name: image, + rbd_pool: null, + size: 1073741824 + }); + }); + + it('should give error on invalid image name', () => { + formHelper.setValue('image', '/ghfhdlk;kd;@'); + component.onSubmit(); + formHelper.expectError('image', 'pattern'); + }); + + it('should give error on invalid image size', () => { + formHelper.setValue('image_size', -56); + component.onSubmit(); + formHelper.expectError('image_size', 'pattern'); + }); + + it('should give error on 0 image size', () => { + formHelper.setValue('image_size', 0); + component.onSubmit(); + formHelper.expectError('image_size', 'min'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts new file mode 100644 index 00000000000..f5721e11ab6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts @@ -0,0 +1,192 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { + NamespaceCreateRequest, + NamespaceEditRequest, + NvmeofService +} from '~/app/shared/api/nvmeof.service'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof'; +import { Permission } from '~/app/shared/models/permissions'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { Pool } from '../../pool/pool'; +import { PoolService } from '~/app/shared/api/pool.service'; +import { RbdService } from '~/app/shared/api/rbd.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; +import { Observable } from 'rxjs'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; + +@Component({ + selector: 'cd-nvmeof-namespaces-form', + templateUrl: './nvmeof-namespaces-form.component.html', + styleUrls: ['./nvmeof-namespaces-form.component.scss'] +}) +export class NvmeofNamespacesFormComponent implements OnInit { + action: string; + permission: Permission; + poolPermission: Permission; + resource: string; + pageURL: string; + edit: boolean = false; + nsForm: CdFormGroup; + subsystemNQN: string; + rbdPools: Array = null; + units: Array = ['KiB', 'MiB', 'GiB', 'TiB']; + nsid: string; + currentBytes: number; + invalidSizeError: boolean; + + constructor( + public actionLabels: ActionLabelsI18n, + private authStorageService: AuthStorageService, + private taskWrapperService: TaskWrapperService, + private nvmeofService: NvmeofService, + private poolService: PoolService, + private rbdService: RbdService, + private router: Router, + private route: ActivatedRoute, + public activeModal: NgbActiveModal, + public formatterService: FormatterService, + public dimlessBinaryPipe: DimlessBinaryPipe + ) { + this.permission = this.authStorageService.getPermissions().nvmeof; + this.poolPermission = this.authStorageService.getPermissions().pool; + this.resource = $localize`Namespace`; + this.pageURL = 'block/nvmeof/subsystems'; + } + + init() { + this.createForm(); + this.action = this.actionLabels.CREATE; + this.route.params.subscribe((params: { subsystem_nqn: string; nsid: string }) => { + this.subsystemNQN = params.subsystem_nqn; + this.nsid = params?.nsid; + }); + } + + initForEdit() { + this.edit = true; + this.action = this.actionLabels.EDIT; + this.nvmeofService + .getNamespace(this.subsystemNQN, this.nsid) + .subscribe((res: NvmeofSubsystemNamespace) => { + const convertedSize = this.dimlessBinaryPipe.transform(res.rbd_image_size).split(' '); + this.currentBytes = res.rbd_image_size; + this.nsForm.get('image').setValue(res.rbd_image_name); + this.nsForm.get('pool').setValue(res.rbd_pool_name); + this.nsForm.get('unit').setValue(convertedSize[1]); + this.nsForm.get('image_size').setValue(convertedSize[0]); + this.nsForm.get('image_size').addValidators(Validators.required); + this.nsForm.get('image').disable(); + this.nsForm.get('pool').disable(); + }); + } + + initForCreate() { + this.poolService.getList().subscribe((resp: Pool[]) => { + this.rbdPools = resp.filter(this.rbdService.isRBDPool); + }); + } + + ngOnInit() { + this.init(); + if (this.router.url.includes('subsystems/(modal:edit')) { + this.initForEdit(); + } else { + this.initForCreate(); + } + } + + createForm() { + this.nsForm = new CdFormGroup({ + image: new UntypedFormControl(`nvme_ns_image:${Date.now()}`, { + validators: [Validators.required, Validators.pattern(/^[^@/]+?$/)] + }), + pool: new UntypedFormControl(null, { + validators: [Validators.required] + }), + image_size: new UntypedFormControl(1, [CdValidators.number(false), Validators.min(1)]), + unit: new UntypedFormControl(this.units[2]) + }); + } + + buildRequest(): NamespaceCreateRequest | NamespaceEditRequest { + const image_size = this.nsForm.getValue('image_size'); + const image_size_unit = this.nsForm.getValue('unit'); + const request = {} as NamespaceCreateRequest | NamespaceEditRequest; + if (image_size) { + const key: string = this.edit ? 'rbd_image_size' : 'size'; + const value: number = this.formatterService.toBytes(image_size + image_size_unit); + request[key] = value; + } + if (!this.edit) { + const image = this.nsForm.getValue('image'); + const pool = this.nsForm.getValue('pool'); + request['rbd_image_name'] = image; + request['rbd_pool'] = pool; + } + return request; + } + + validateSize() { + const unit = this.nsForm.getValue('unit'); + const image_size = this.nsForm.getValue('image_size'); + if (image_size && unit) { + const bytes = this.formatterService.toBytes(image_size + unit); + return bytes <= this.currentBytes; + } + return null; + } + + onSubmit() { + if (this.validateSize()) { + this.invalidSizeError = true; + this.nsForm.setErrors({ cdSubmitButton: true }); + } else { + this.invalidSizeError = false; + const component = this; + const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`; + const request = this.buildRequest(); + let action: Observable; + + if (this.edit) { + action = this.taskWrapperService.wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + nqn: this.subsystemNQN, + nsid: this.nsid + }), + call: this.nvmeofService.updateNamespace( + this.subsystemNQN, + this.nsid, + request as NamespaceEditRequest + ) + }); + } else { + action = this.taskWrapperService.wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + nqn: this.subsystemNQN + }), + call: this.nvmeofService.createNamespace( + this.subsystemNQN, + request as NamespaceCreateRequest + ) + }); + } + + action.subscribe({ + error() { + component.nsForm.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-namespaces-list/nvmeof-namespaces-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html new file mode 100644 index 00000000000..bebe1700f5a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html @@ -0,0 +1,20 @@ + + + An NVMe namespace is a quantity of non-volatile storage that can be formatted into logical blocks and presented to a host as a standard block device. + + + + +
+ + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts new file mode 100644 index 00000000000..75562626ee5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts @@ -0,0 +1,84 @@ +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 { NvmeofTabsComponent } from '../nvmeof-tabs/nvmeof-tabs.component'; +import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component'; +import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list.component'; + +const mockNamespaces = [ + { + nsid: 1, + uuid: 'f4396245-186f-401a-b71c-945ccf0f0cc9', + bdev_name: 'bdev_f4396245-186f-401a-b71c-945ccf0f0cc9', + rbd_image_name: 'string', + rbd_pool_name: 'rbd', + load_balancing_group: 1, + rbd_image_size: 1024, + block_size: 512, + rw_ios_per_second: 0, + rw_mbytes_per_second: 0, + r_mbytes_per_second: 0, + w_mbytes_per_second: 0 + } +]; + +class MockNvmeOfService { + listNamespaces() { + return of(mockNamespaces); + } +} + +class MockAuthStorageService { + getPermissions() { + return { nvmeof: {} }; + } +} + +class MockModalService {} + +class MockTaskWrapperService {} + +describe('NvmeofNamespacesListComponent', () => { + let component: NvmeofNamespacesListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [ + NvmeofNamespacesListComponent, + 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(NvmeofNamespacesListComponent); + component = fixture.componentInstance; + component.ngOnInit(); + component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436'; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve namespaces', fakeAsync(() => { + component.listNamespaces(); + tick(); + expect(component.namespaces).toEqual(mockNamespaces); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts new file mode 100644 index 00000000000..cbf963995dd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts @@ -0,0 +1,181 @@ +import { Component, Input, OnChanges, OnInit } from '@angular/core'; +import { Router } from '@angular/router'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof'; +import { Permission } from '~/app/shared/models/permissions'; +import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; +import { IopsPipe } from '~/app/shared/pipes/iops.pipe'; +import { MbpersecondPipe } from '~/app/shared/pipes/mbpersecond.pipe'; +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'; + +const BASE_URL = 'block/nvmeof/subsystems'; + +@Component({ + selector: 'cd-nvmeof-namespaces-list', + templateUrl: './nvmeof-namespaces-list.component.html', + styleUrls: ['./nvmeof-namespaces-list.component.scss'] +}) +export class NvmeofNamespacesListComponent implements OnInit, OnChanges { + @Input() + subsystemNQN: string; + + namespacesColumns: any; + tableActions: CdTableAction[]; + selection = new CdTableSelection(); + permission: Permission; + namespaces: NvmeofSubsystemNamespace[]; + + constructor( + public actionLabels: ActionLabelsI18n, + private router: Router, + private modalService: ModalService, + private authStorageService: AuthStorageService, + private taskWrapper: TaskWrapperService, + private nvmeofService: NvmeofService, + private dimlessBinaryPipe: DimlessBinaryPipe, + private mbPerSecondPipe: MbpersecondPipe, + private iopsPipe: IopsPipe + ) { + this.permission = this.authStorageService.getPermissions().nvmeof; + } + + ngOnInit() { + this.namespacesColumns = [ + { + name: $localize`ID`, + prop: 'nsid' + }, + { + name: $localize`Bdev Name`, + prop: 'bdev_name' + }, + { + name: $localize`Pool `, + prop: 'rbd_pool_name', + flexGrow: 2 + }, + { + name: $localize`Image`, + prop: 'rbd_image_name', + flexGrow: 3 + }, + { + name: $localize`Image Size`, + prop: 'rbd_image_size', + pipe: this.dimlessBinaryPipe + }, + { + name: $localize`Block Size`, + prop: 'block_size', + pipe: this.dimlessBinaryPipe + }, + { + name: $localize`IOPS`, + prop: 'rw_ios_per_second', + sortable: false, + pipe: this.iopsPipe, + flexGrow: 1.5 + }, + { + name: $localize`R/W Throughput`, + prop: 'rw_mbytes_per_second', + sortable: false, + pipe: this.mbPerSecondPipe, + flexGrow: 1.5 + }, + { + name: $localize`Read Throughput`, + prop: 'r_mbytes_per_second', + sortable: false, + pipe: this.mbPerSecondPipe, + flexGrow: 1.5 + }, + { + name: $localize`Write Throughput`, + prop: 'w_mbytes_per_second', + sortable: false, + pipe: this.mbPerSecondPipe, + flexGrow: 1.5 + }, + { + name: $localize`Load Balancing Group`, + prop: 'load_balancing_group', + flexGrow: 1.5 + } + ]; + this.tableActions = [ + { + name: this.actionLabels.CREATE, + permission: 'create', + icon: Icons.add, + click: () => + this.router.navigate([ + BASE_URL, + { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } } + ]), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, + { + name: this.actionLabels.EDIT, + permission: 'update', + icon: Icons.edit, + click: () => + this.router.navigate([ + BASE_URL, + { + outlets: { + modal: [URLVerbs.EDIT, this.subsystemNQN, 'namespace', this.selection.first().nsid] + } + } + ]) + }, + { + name: this.actionLabels.DELETE, + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteSubsystemModal() + } + ]; + } + + ngOnChanges() { + this.listNamespaces(); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + listNamespaces() { + this.nvmeofService + .listNamespaces(this.subsystemNQN) + .subscribe((res: NvmeofSubsystemNamespace[]) => { + this.namespaces = res; + }); + } + + deleteSubsystemModal() { + const namespace = this.selection.first(); + this.modalService.show(CriticalConfirmationModalComponent, { + itemDescription: 'Namespace', + itemNames: [namespace.nsid], + actionDescription: 'delete', + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/namespace/delete', { + nqn: this.subsystemNQN, + nsid: namespace.nsid + }), + call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid) + }) + }); + } +} 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 index 5b551bdb966..e9001805ddc 100644 --- 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 @@ -19,6 +19,13 @@ + + Namespaces + + + +
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 index 93e27094e17..a12846e5dac 100644 --- 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 @@ -21,7 +21,7 @@ type="text" formControlName="nqn"> - The NVMe Qualified Name (NQN) is a unique and permanent name for the lifetime of the subsystem. + A unique and permanent name for the lifetime of the subsystem. This NQN is already in use. An NQN should follow the format of
<nqn.$year-$month.$reverseDomainName:$definedName".>
+ i18n>Expected NQN format
<nqn.$year-$month.$reverseDomainName:$utf8-string".> or
<nqn.2014-08.org.nvmexpress:uuid:$UUID-string".> An NQN should not be more than 223 bytes in length. 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 index bfa642de1cb..4e9c420c9f0 100644 --- 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 @@ -22,6 +22,7 @@ describe('NvmeofSubsystemsFormComponent', () => { const mockTimestamp = 1720693470789; beforeEach(async () => { + spyOn(Date, 'now').and.returnValue(mockTimestamp); await TestBed.configureTestingModule({ declarations: [NvmeofSubsystemsFormComponent], providers: [NgbActiveModal], @@ -40,7 +41,6 @@ describe('NvmeofSubsystemsFormComponent', () => { component.ngOnInit(); form = component.subsystemForm; formHelper = new FormHelper(form); - spyOn(Date, 'now').and.returnValue(mockTimestamp); fixture.detectChanges(); }); 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 index 2af218af54a..775aed08c67 100644 --- 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 @@ -25,8 +25,6 @@ export class NvmeofSubsystemsFormComponent implements OnInit { 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, @@ -40,6 +38,16 @@ export class NvmeofSubsystemsFormComponent implements OnInit { this.pageURL = 'block/nvmeof/subsystems'; } + DEFAULT_NQN = 'nqn.2001-07.com.ceph:' + Date.now(); + NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/; + NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + + customNQNValidator = CdValidators.custom( + 'pattern', + (nqnInput: string) => + !!nqnInput && !(this.NQN_REGEX.test(nqnInput) || this.NQN_REGEX_UUID.test(nqnInput)) + ); + ngOnInit() { this.createForm(); this.action = this.actionLabels.CREATE; @@ -47,8 +55,9 @@ export class NvmeofSubsystemsFormComponent implements OnInit { createForm() { this.subsystemForm = new CdFormGroup({ - nqn: new UntypedFormControl('nqn.2001-07.com.ceph:' + Date.now(), { + nqn: new UntypedFormControl(this.DEFAULT_NQN, { validators: [ + this.customNQNValidator, Validators.required, Validators.pattern(this.NQN_REGEX), CdValidators.custom( 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 index d281901627b..8c4b3cbd26e 100644 --- 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 @@ -53,7 +53,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit prop: 'namespace_count' }, { - name: $localize`# Maximum Namespaces`, + name: $localize`# Maximum Allowed Namespaces`, prop: 'max_namespaces' } ]; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts index dd6aba7cf6c..d021906f46b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts @@ -30,4 +30,37 @@ describe('NvmeofService', () => { const req = httpTesting.expectOne('api/nvmeof/gateway'); expect(req.request.method).toBe('GET'); }); + + it('should call getSubsystem', () => { + service.getSubsystem('nqn.2001-07.com.ceph:1721041732363').subscribe(); + const req = httpTesting.expectOne('api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363'); + expect(req.request.method).toBe('GET'); + }); + + it('should call createSubsystem', () => { + const request = { + nqn: 'nqn.2001-07.com.ceph:1721041732363', + enable_ha: true, + initiators: '*' + }; + service.createSubsystem(request).subscribe(); + const req = httpTesting.expectOne('api/nvmeof/subsystem'); + expect(req.request.method).toBe('POST'); + }); + + it('should call getInitiators', () => { + service.getInitiators('nqn.2001-07.com.ceph:1721041732363').subscribe(); + const req = httpTesting.expectOne( + 'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host' + ); + expect(req.request.method).toBe('GET'); + }); + + it('should call updateInitiators', () => { + service.updateInitiators('nqn.2001-07.com.ceph:1721041732363', '*').subscribe(); + const req = httpTesting.expectOne( + 'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host/*' + ); + expect(req.request.method).toBe('PUT'); + }); }); 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 d9375ba0de3..063693de61d 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 @@ -11,6 +11,16 @@ export interface ListenerRequest { trsvcid: number; } +export interface NamespaceCreateRequest { + rbd_image_name: string; + rbd_pool: string; + size: number; +} + +export interface NamespaceEditRequest { + rbd_image_size: number; +} + const BASE_URL = 'api/nvmeof'; @Injectable({ @@ -19,10 +29,12 @@ const BASE_URL = 'api/nvmeof'; export class NvmeofService { constructor(private http: HttpClient) {} + // Gateways listGateways() { return this.http.get(`${BASE_URL}/gateway`); } + // Subsystems listSubsystems() { return this.http.get(`${BASE_URL}/subsystem`); } @@ -51,7 +63,22 @@ export class NvmeofService { ); } - // listeners + // Initiators + getInitiators(subsystemNQN: string) { + return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/host`); + } + + updateInitiators(subsystemNQN: string, hostNQN: string) { + return this.http.put( + `${BASE_URL}/subsystem/${subsystemNQN}/host/${hostNQN}`, + {}, + { + observe: 'response' + } + ); + } + + // Listeners listListeners(subsystemNQN: string) { return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/listener`); } @@ -73,4 +100,31 @@ export class NvmeofService { } ); } + + // Namespaces + listNamespaces(subsystemNQN: string) { + return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`); + } + + getNamespace(subsystemNQN: string, nsid: string) { + return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`); + } + + createNamespace(subsystemNQN: string, request: NamespaceCreateRequest) { + return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`, request, { + observe: 'response' + }); + } + + updateNamespace(subsystemNQN: string, nsid: string, request: NamespaceEditRequest) { + return this.http.patch(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, { + observe: 'response' + }); + } + + deleteNamespace(subsystemNQN: string, nsid: string) { + return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, { + observe: 'response' + }); + } } 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 e86dae7dcd9..21969db73c8 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 @@ -28,3 +28,22 @@ export interface NvmeofListener { trsvcid: number; // 4420 id?: number; // for table } + +export interface NvmeofSubsystemHost { + nqn: string; +} + +export interface NvmeofSubsystemNamespace { + nsid: number; + uuid: string; + bdev_name: string; + rbd_image_name: string; + rbd_pool_name: string; + load_balancing_group: number; + rbd_image_size: number; + block_size: number; + rw_ios_per_second: number; + rw_mbytes_per_second: number; + r_mbytes_per_second: number; + w_mbytes_per_second: number; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.spec.ts new file mode 100644 index 00000000000..d21cbe14dfa --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.spec.ts @@ -0,0 +1,8 @@ +import { MbpersecondPipe } from './mbpersecond.pipe'; + +describe('MbpersecondPipe', () => { + it('create an instance', () => { + const pipe = new MbpersecondPipe(); + expect(pipe).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.ts new file mode 100644 index 00000000000..1a007faf782 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.ts @@ -0,0 +1,10 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +@Pipe({ + name: 'mbpersecond' +}) +export class MbpersecondPipe implements PipeTransform { + transform(value: any): any { + return `${value} MB/s`; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts index 025eb72a47b..fadc21e40c9 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts @@ -39,6 +39,7 @@ import { OctalToHumanReadablePipe } from './octal-to-human-readable.pipe'; import { PathPipe } from './path.pipe'; import { PluralizePipe } from './pluralize.pipe'; import { XmlPipe } from './xml.pipe'; +import { MbpersecondPipe } from './mbpersecond.pipe'; @NgModule({ imports: [CommonModule], @@ -80,7 +81,8 @@ import { XmlPipe } from './xml.pipe'; OctalToHumanReadablePipe, PathPipe, PluralizePipe, - XmlPipe + XmlPipe, + MbpersecondPipe ], exports: [ ArrayPipe, @@ -120,7 +122,8 @@ import { XmlPipe } from './xml.pipe'; OctalToHumanReadablePipe, PathPipe, PluralizePipe, - XmlPipe + XmlPipe, + MbpersecondPipe ], providers: [ ArrayPipe, @@ -152,7 +155,8 @@ import { XmlPipe } from './xml.pipe'; MgrSummaryPipe, MdsSummaryPipe, OsdSummaryPipe, - OctalToHumanReadablePipe + OctalToHumanReadablePipe, + MbpersecondPipe ] }) export class PipesModule {} 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 4ae4a5bc028..8e615327114 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 @@ -367,6 +367,18 @@ export class TaskMessageService { 'nvmeof/listener/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.nvmeofListener(metadata) ), + 'nvmeof/subsystem/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.nvmeofSubsystem(metadata) + ), + 'nvmeof/namespace/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.nvmeofNamespace(metadata) + ), + 'nvmeof/namespace/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.nvmeofNamespace(metadata) + ), + 'nvmeof/namespace/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => + this.nvmeofNamespace(metadata) + ), // nfs 'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) => this.nfs(metadata) @@ -501,6 +513,13 @@ export class TaskMessageService { return $localize`listener '${metadata.host_name} on subsystem ${metadata.nqn}`; } + nvmeofNamespace(metadata: any) { + if (metadata?.nsid) { + return $localize`namespace ${metadata.nsid} for subsystem '${metadata.nqn}'`; + } + return $localize`namespace for subsystem '${metadata.nqn}'`; + } + nfs(metadata: any) { return $localize`NFS '${metadata.cluster_id}\:${ metadata.export_id ? metadata.export_id : metadata.path -- 2.39.5