From 2e520f8f96b68adf6db70ade43f80914015449e1 Mon Sep 17 00:00:00 2001 From: Afreen Misbah Date: Wed, 10 Jul 2024 16:54:20 +0530 Subject: [PATCH] mgr/dashboard: Add initiators add/update in dashboard Fixes https://tracker.ceph.com/issues/66907 - add one or more initiators - remove one or more initiators - introduces two new UI routers for the above two Signed-off-by: Afreen Misbah (cherry picked from commit 1f82dc8b8f1b5ca17caa69fbbb99554b5a659591) --- .../mgr/dashboard/controllers/nvmeof.py | 85 ++++++++--- .../src/app/ceph/block/block.module.ts | 20 ++- .../nvmeof-initiators-form.component.html | 104 ++++++++++++++ .../nvmeof-initiators-form.component.scss | 0 .../nvmeof-initiators-form.component.spec.ts | 61 ++++++++ .../nvmeof-initiators-form.component.ts | 135 ++++++++++++++++++ .../nvmeof-initiators-list.component.html | 27 ++++ .../nvmeof-initiators-list.component.scss | 0 .../nvmeof-initiators-list.component.spec.ts | 68 +++++++++ .../nvmeof-initiators-list.component.ts | 125 ++++++++++++++++ .../nvmeof-listeners-form.component.spec.ts | 9 -- .../nvmeof-listeners-list.component.spec.ts | 2 +- .../nvmeof-namespaces-form.component.html | 2 +- .../nvmeof-subsystems-details.component.html | 7 + .../nvmeof-subsystems-details.component.ts | 1 + .../nvmeof-subsystems-form.component.html | 2 +- .../nvmeof-subsystems-form.component.ts | 9 +- .../nvmeof-subsystems.component.ts | 18 +++ .../src/app/shared/api/nvmeof.service.spec.ts | 8 -- .../src/app/shared/api/nvmeof.service.ts | 53 ++++--- .../frontend/src/app/shared/models/nvmeof.ts | 8 +- .../shared/services/task-message.service.ts | 15 +- 22 files changed, 674 insertions(+), 85 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index 84d7a37952e72..e050ecdf6a6c0 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -7,8 +7,9 @@ from ..model import nvmeof as model from ..security import Scope from ..services.orchestrator import OrchClient from ..tools import str_to_bool -from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, Param, \ - ReadPermission, RESTController, UIRouter +from . import APIDoc, APIRouter, BaseController, CreatePermission, \ + DeletePermission, Endpoint, EndpointDoc, Param, ReadPermission, \ + RESTController, UIRouter logger = logging.getLogger(__name__) @@ -392,23 +393,65 @@ else: NVMeoFClient.pb2.list_connections_req(subsystem=nqn) ) + @UIRouter('/nvmeof', Scope.NVME_OF) + class NVMeoFTcpUI(BaseController): + @Endpoint('GET', '/status') + @ReadPermission + @EndpointDoc("Display NVMe/TCP service status", + responses={200: NVME_SCHEMA}) + def status(self) -> dict: + status: Dict[str, Any] = {'available': True, 'message': None} + orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator') + if orch_backend == 'cephadm': + orch = OrchClient.instance() + orch_status = orch.status() + if not orch_status['available']: + return status + if not orch.services.list_daemons(daemon_type='nvmeof'): + status["available"] = False + status["message"] = 'An NVMe/TCP service must be created.' + return status + + @Endpoint('POST', "/subsystem/{subsystem_nqn}/host") + @EndpointDoc("Add one or more initiator hosts to an NVMeoF subsystem", + parameters={ + 'subsystem_nqn': (str, 'Subsystem NQN'), + "host_nqn": Param(str, 'Comma separated list of NVMeoF host NQNs'), + }) + @empty_response + @handle_nvmeof_error + @CreatePermission + def add(self, subsystem_nqn: str, host_nqn: str = ""): + response = None + all_host_nqns = host_nqn.split(',') + + for nqn in all_host_nqns: + response = NVMeoFClient().stub.add_host( + NVMeoFClient.pb2.add_host_req(subsystem_nqn=subsystem_nqn, host_nqn=nqn) + ) + if response.status != 0: + return response + return response -@UIRouter('/nvmeof', Scope.NVME_OF) -@APIDoc("NVMe/TCP Management API", "NVMe/TCP") -class NVMeoFStatus(BaseController): - @Endpoint() - @ReadPermission - @EndpointDoc("Display NVMe/TCP service Status", - responses={200: NVME_SCHEMA}) - def status(self) -> dict: - status: Dict[str, Any] = {'available': True, 'message': None} - orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator') - if orch_backend == 'cephadm': - orch = OrchClient.instance() - orch_status = orch.status() - if not orch_status['available']: - return status - if not orch.services.list_daemons(daemon_type='nvmeof'): - status["available"] = False - status["message"] = 'Create an NVMe/TCP service to get started.' - return status + @Endpoint(method='DELETE', path="/subsystem/{subsystem_nqn}/host/{host_nqn}") + @EndpointDoc("Remove on or more initiator hosts from an NVMeoF subsystem", + parameters={ + "subsystem_nqn": Param(str, "NVMeoF subsystem NQN"), + "host_nqn": Param(str, 'Comma separated list of NVMeoF host NQN.'), + }) + @empty_response + @handle_nvmeof_error + @DeletePermission + def remove(self, subsystem_nqn: str, host_nqn: str): + response = None + to_delete_nqns = host_nqn.split(',') + + for del_nqn in to_delete_nqns: + response = NVMeoFClient().stub.remove_host( + NVMeoFClient.pb2.remove_host_req(subsystem_nqn=subsystem_nqn, host_nqn=del_nqn) + ) + if response.status != 0: + return response + logger.info("removed host %s from subsystem %s", del_nqn, subsystem_nqn) + + return response 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 dec04b46387a3..8d377ff24511d 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 @@ -47,6 +47,8 @@ import { NvmeofListenersFormComponent } from './nvmeof-listeners-form/nvmeof-lis 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'; +import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list/nvmeof-initiators-list.component'; +import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-initiators-form.component'; @NgModule({ imports: [ @@ -95,7 +97,9 @@ import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form/nvmeof-n NvmeofListenersFormComponent, NvmeofListenersListComponent, NvmeofNamespacesListComponent, - NvmeofNamespacesFormComponent + NvmeofNamespacesFormComponent, + NvmeofInitiatorsListComponent, + NvmeofInitiatorsFormComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] }) @@ -249,15 +253,11 @@ const routes: Routes = [ component: NvmeofSubsystemsFormComponent, outlet: 'modal' }, - { - path: `${URLVerbs.EDIT}/:subsystem_nqn/:max_ns`, - component: NvmeofSubsystemsFormComponent, - outlet: 'modal' - }, // listeners { path: `${URLVerbs.CREATE}/:subsystem_nqn/listener`, - component: NvmeofListenersFormComponent + component: NvmeofListenersFormComponent, + outlet: 'modal' }, // namespaces { @@ -269,6 +269,12 @@ const routes: Routes = [ path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`, component: NvmeofNamespacesFormComponent, outlet: 'modal' + }, + // initiators + { + path: `${URLVerbs.ADD}/:subsystem_nqn/initiator`, + component: NvmeofInitiatorsFormComponent, + outlet: 'modal' } ] }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html new file mode 100644 index 0000000000000..a0a61d7ae480b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html @@ -0,0 +1,104 @@ + + {{ action | titlecase }} {{ resource | upperFirst }} + +
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts new file mode 100644 index 0000000000000..f6da04f5ec071 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts @@ -0,0 +1,61 @@ +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 { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form.component'; + +describe('NvmeofInitiatorsFormComponent', () => { + let component: NvmeofInitiatorsFormComponent; + let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + const mockTimestamp = 1720693470789; + + beforeEach(async () => { + spyOn(Date, 'now').and.returnValue(mockTimestamp); + await TestBed.configureTestingModule({ + declarations: [NvmeofInitiatorsFormComponent], + providers: [NgbActiveModal], + imports: [ + HttpClientTestingModule, + NgbTypeaheadModule, + ReactiveFormsModule, + RouterTestingModule, + SharedModule, + ToastrModule.forRoot() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofInitiatorsFormComponent); + component = fixture.componentInstance; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('should test form', () => { + beforeEach(() => { + nvmeofService = TestBed.inject(NvmeofService); + spyOn(nvmeofService, 'addInitiators').and.stub(); + }); + + it('should be creating request correctly', () => { + const subsystemNQN = 'nqn.2001-07.com.ceph:' + mockTimestamp; + component.subsystemNQN = subsystemNQN; + component.onSubmit(); + expect(nvmeofService.addInitiators).toHaveBeenCalledWith(subsystemNQN, { + host_nqn: '' + }); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts new file mode 100644 index 0000000000000..3a143a1a8df90 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts @@ -0,0 +1,135 @@ +import { Component, OnInit } from '@angular/core'; +import { UntypedFormArray, UntypedFormControl, Validators } from '@angular/forms'; + +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +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 { ActivatedRoute, Router } from '@angular/router'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +@Component({ + selector: 'cd-nvmeof-initiators-form', + templateUrl: './nvmeof-initiators-form.component.html', + styleUrls: ['./nvmeof-initiators-form.component.scss'] +}) +export class NvmeofInitiatorsFormComponent implements OnInit { + permission: Permission; + initiatorForm: CdFormGroup; + action: string; + resource: string; + pageURL: string; + remove: boolean = false; + subsystemNQN: string; + removeHosts: { name: string; value: boolean; id: number }[] = []; + + constructor( + private authStorageService: AuthStorageService, + public actionLabels: ActionLabelsI18n, + private nvmeofService: NvmeofService, + private taskWrapperService: TaskWrapperService, + private router: Router, + private route: ActivatedRoute, + private formBuilder: CdFormBuilder + ) { + this.permission = this.authStorageService.getPermissions().nvmeof; + this.resource = $localize`Initiator`; + this.pageURL = 'block/nvmeof/subsystems'; + } + + 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}$/; + ALLOW_ALL_HOST = '*'; + + 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.ADD; + this.route.params.subscribe((params: { subsystem_nqn: string }) => { + this.subsystemNQN = params.subsystem_nqn; + }); + } + + createForm() { + this.initiatorForm = new CdFormGroup({ + allowAnyHost: new UntypedFormControl(false), + addHost: new CdFormGroup({ + addHostCheck: new UntypedFormControl(false), + addedHosts: this.formBuilder.array( + [], + [ + CdValidators.custom( + 'duplicate', + (hosts: string[]) => !!hosts.length && new Set(hosts)?.size !== hosts.length + ) + ] + ) + }) + }); + } + + get addedHosts(): UntypedFormArray { + return this.initiatorForm.get('addHost.addedHosts') as UntypedFormArray; + } + + addHost() { + let newHostFormGroup; + newHostFormGroup = this.formBuilder.control('', [this.customNQNValidator, Validators.required]); + this.addedHosts.push(newHostFormGroup); + } + + removeHost(index: number) { + this.addedHosts.removeAt(index); + } + + setAddHostCheck() { + const addHostCheck = this.initiatorForm.get('addHost.addHostCheck').value; + if (!addHostCheck) { + while (this.addedHosts.length !== 0) { + this.addedHosts.removeAt(0); + } + } else { + this.addHost(); + } + } + + onSubmit() { + const component = this; + const allowAnyHost: boolean = this.initiatorForm.getValue('allowAnyHost'); + const hosts: string[] = this.addedHosts.value; + let taskUrl = `nvmeof/initiator/${URLVerbs.ADD}`; + + const request = { + host_nqn: hosts.join(',') + }; + + if (allowAnyHost) { + hosts.push('*'); + request['host_nqn'] = hosts.join(','); + } + this.taskWrapperService + .wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + nqn: this.subsystemNQN + }), + call: this.nvmeofService.addInitiators(this.subsystemNQN, request) + }) + .subscribe({ + error() { + component.initiatorForm.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-initiators-list/nvmeof-initiators-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html new file mode 100644 index 0000000000000..29ebbe645d131 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html @@ -0,0 +1,27 @@ + + + The client that connects to the NVMe-oF target to access NVMe storage. + + + +
+ + +
+
+ + Any host allowed (*) + {{value}} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts new file mode 100644 index 0000000000000..f8d9c67363251 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts @@ -0,0 +1,68 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { HttpClientModule } from '@angular/common/http'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { of } from 'rxjs'; + +import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofService } from '~/app/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 { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list.component'; + +const mockInitiators = [ + { + nqn: '*' + } +]; + +class MockNvmeOfService { + getInitiators() { + return of(mockInitiators); + } +} + +class MockAuthStorageService { + getPermissions() { + return { nvmeof: {} }; + } +} + +class MockModalService {} + +class MockTaskWrapperService {} + +describe('NvmeofInitiatorsListComponent', () => { + let component: NvmeofInitiatorsListComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofInitiatorsListComponent], + 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(NvmeofInitiatorsListComponent); + component = fixture.componentInstance; + component.ngOnInit(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should retrieve initiators', fakeAsync(() => { + component.listInitiators(); + tick(); + expect(component.initiators).toEqual(mockInitiators); + })); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts new file mode 100644 index 0000000000000..2491ccc0cb898 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts @@ -0,0 +1,125 @@ +import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } 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 { NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof'; +import { Permission } from '~/app/shared/models/permissions'; +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-initiators-list', + templateUrl: './nvmeof-initiators-list.component.html', + styleUrls: ['./nvmeof-initiators-list.component.scss'] +}) +export class NvmeofInitiatorsListComponent implements OnInit, OnChanges { + @Input() + subsystemNQN: string; + + @ViewChild('hostTpl', { static: true }) + hostTpl: TemplateRef; + + initiatorColumns: any; + tableActions: CdTableAction[]; + selection = new CdTableSelection(); + permission: Permission; + initiators: NvmeofSubsystemInitiator[] = []; + + constructor( + public actionLabels: ActionLabelsI18n, + private authStorageService: AuthStorageService, + private nvmeofService: NvmeofService, + private modalService: ModalService, + private router: Router, + private taskWrapper: TaskWrapperService + ) { + this.permission = this.authStorageService.getPermissions().nvmeof; + } + + ngOnInit() { + this.initiatorColumns = [ + { + name: $localize`Initiator`, + prop: 'nqn', + cellTemplate: this.hostTpl + } + ]; + this.tableActions = [ + { + name: this.actionLabels.ADD, + permission: 'create', + icon: Icons.add, + click: () => + this.router.navigate([ + BASE_URL, + { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } } + ]), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }, + { + name: this.actionLabels.REMOVE, + permission: 'delete', + icon: Icons.destroy, + click: () => this.removeInitiatorModal(), + disable: () => !this.selection.hasSelection, + canBePrimary: (selection: CdTableSelection) => selection.hasSelection + } + ]; + } + + getAllowAllHostIndex() { + return this.selection.selected.findIndex((selected) => selected.nqn === '*'); + } + + ngOnChanges() { + this.listInitiators(); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + listInitiators() { + this.nvmeofService + .getInitiators(this.subsystemNQN) + .subscribe((initiators: NvmeofSubsystemInitiator[]) => { + this.initiators = initiators; + }); + } + + getSelectedNQNs() { + return this.selection.selected.map((selected) => selected.nqn); + } + + removeInitiatorModal() { + const hostNQNs = this.getSelectedNQNs(); + const allowAllHostIndex = this.getAllowAllHostIndex(); + const host_nqn = hostNQNs.join(','); + let itemNames = hostNQNs; + if (allowAllHostIndex !== -1) { + hostNQNs.splice(allowAllHostIndex, 1); + itemNames = [...hostNQNs, $localize`Allow any host(*)`]; + } + this.modalService.show(CriticalConfirmationModalComponent, { + itemDescription: 'Initiator', + itemNames, + actionDescription: 'remove', + submitActionObservable: () => + this.taskWrapper.wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/initiator/remove', { + nqn: this.subsystemNQN, + plural: itemNames.length > 1 + }), + call: this.nvmeofService.removeInitiators(this.subsystemNQN, { host_nqn }) + }) + }); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.spec.ts index b115fd5b6f6fb..74bad35b13cdb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.spec.ts @@ -7,13 +7,11 @@ import { ToastrModule } from 'ngx-toastr'; import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from '~/app/shared/shared.module'; -import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { NvmeofListenersFormComponent } from './nvmeof-listeners-form.component'; describe('NvmeofListenersFormComponent', () => { let component: NvmeofListenersFormComponent; let fixture: ComponentFixture; - let nvmeofService: NvmeofService; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -38,11 +36,4 @@ describe('NvmeofListenersFormComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); - - describe('should test form', () => { - beforeEach(() => { - nvmeofService = TestBed.inject(NvmeofService); - spyOn(nvmeofService, 'createListener').and.stub(); - }); - }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.spec.ts index ecf8c4959af2e..01a436022fa66 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.spec.ts @@ -62,7 +62,7 @@ describe('NvmeofListenersListComponent', () => { expect(component).toBeTruthy(); }); - it('should retrieve subsystems', fakeAsync(() => { + it('should retrieve listeners', fakeAsync(() => { component.listListeners(); tick(); expect(component.listeners).toEqual(mockListeners); 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 index f1e222bfd7149..72576b7e6426d 100644 --- 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 @@ -100,7 +100,7 @@ - Enter a value above than previous. + Enter a value above than previous. A block device image can be expanded but not reduced. 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 e9001805ddcda..3749d47bccfa0 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 @@ -26,6 +26,13 @@ + + Initiators + + + +
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 index 5e8abf9a4852f..211905f285fbf 100644 --- 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 @@ -18,6 +18,7 @@ export class NvmeofSubsystemsDetailsComponent implements OnChanges { if (this.selection) { this.selectedItem = this.selection; this.subsystemNQN = this.selectedItem.nqn; + this.data = {}; this.data[$localize`Serial Number`] = this.selectedItem.serial_number; this.data[$localize`Model Number`] = this.selectedItem.model_number; 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 a12846e5dac8a..1032a0d1e26f2 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 @@ -34,7 +34,7 @@ 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. + i18n>An NQN may 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.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts index 775aed08c67fa..5debb52c4d3ab 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 @@ -20,7 +20,6 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service'; export class NvmeofSubsystemsFormComponent implements OnInit { permission: Permission; subsystemForm: CdFormGroup; - action: string; resource: string; pageURL: string; @@ -59,7 +58,7 @@ export class NvmeofSubsystemsFormComponent implements OnInit { validators: [ this.customNQNValidator, Validators.required, - Validators.pattern(this.NQN_REGEX), + this.customNQNValidator, CdValidators.custom( 'maxLength', (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223 @@ -78,7 +77,8 @@ export class NvmeofSubsystemsFormComponent implements OnInit { onSubmit() { const component = this; const nqn: string = this.subsystemForm.getValue('nqn'); - let max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces')); + const max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces')); + let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`; const request = { nqn, @@ -89,9 +89,6 @@ export class NvmeofSubsystemsFormComponent implements OnInit { if (!max_namespaces) { delete request.max_namespaces; } - - let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`; - this.taskWrapperService .wrapTaskAroundCall({ task: new FinishedTask(taskUrl, { 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 8c4b3cbd26e9a..8626dfc2ef0eb 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 @@ -65,6 +65,24 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit click: () => this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]), 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.selection.first().nqn, + this.selection.first().max_namespaces + ] + } + } + ]) + }, { name: this.actionLabels.DELETE, permission: 'delete', 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 d021906f46b35..bbc38b9ce16d5 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 @@ -55,12 +55,4 @@ describe('NvmeofService', () => { ); 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 063693de61da8..4b4c4e86693ca 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 @@ -21,7 +21,12 @@ export interface NamespaceEditRequest { rbd_image_size: number; } -const BASE_URL = 'api/nvmeof'; +export interface InitiatorRequest { + host_nqn: string; +} + +const API_PATH = 'api/nvmeof'; +const UI_API_PATH = 'ui-api/nvmeof'; @Injectable({ providedIn: 'root' @@ -31,24 +36,24 @@ export class NvmeofService { // Gateways listGateways() { - return this.http.get(`${BASE_URL}/gateway`); + return this.http.get(`${API_PATH}/gateway`); } // Subsystems listSubsystems() { - return this.http.get(`${BASE_URL}/subsystem`); + return this.http.get(`${API_PATH}/subsystem`); } getSubsystem(subsystemNQN: string) { - return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}`); + return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}`); } createSubsystem(request: { nqn: string; max_namespaces?: number; enable_ha: boolean }) { - return this.http.post(`${BASE_URL}/subsystem`, request, { observe: 'response' }); + return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' }); } deleteSubsystem(subsystemNQN: string) { - return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}`, { + return this.http.delete(`${API_PATH}/subsystem/${subsystemNQN}`, { observe: 'response' }); } @@ -65,33 +70,35 @@ export class NvmeofService { // Initiators getInitiators(subsystemNQN: string) { - return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/host`); + return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/host`); } - updateInitiators(subsystemNQN: string, hostNQN: string) { - return this.http.put( - `${BASE_URL}/subsystem/${subsystemNQN}/host/${hostNQN}`, - {}, - { - observe: 'response' - } - ); + addInitiators(subsystemNQN: string, request: InitiatorRequest) { + return this.http.post(`${UI_API_PATH}/subsystem/${subsystemNQN}/host`, request, { + observe: 'response' + }); + } + + removeInitiators(subsystemNQN: string, request: InitiatorRequest) { + return this.http.delete(`${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}`, { + observe: 'response' + }); } // Listeners listListeners(subsystemNQN: string) { - return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/listener`); + return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/listener`); } createListener(subsystemNQN: string, request: ListenerRequest) { - return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/listener`, request, { + return this.http.post(`${API_PATH}/subsystem/${subsystemNQN}/listener`, request, { observe: 'response' }); } deleteListener(subsystemNQN: string, hostName: string, traddr: string, trsvcid: string) { return this.http.delete( - `${BASE_URL}/subsystem/${subsystemNQN}/listener/${hostName}/${traddr}`, + `${API_PATH}/subsystem/${subsystemNQN}/listener/${hostName}/${traddr}`, { observe: 'response', params: { @@ -103,27 +110,27 @@ export class NvmeofService { // Namespaces listNamespaces(subsystemNQN: string) { - return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`); + return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/namespace`); } getNamespace(subsystemNQN: string, nsid: string) { - return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`); + return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`); } createNamespace(subsystemNQN: string, request: NamespaceCreateRequest) { - return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`, request, { + return this.http.post(`${API_PATH}/subsystem/${subsystemNQN}/namespace`, request, { observe: 'response' }); } updateNamespace(subsystemNQN: string, nsid: string, request: NamespaceEditRequest) { - return this.http.patch(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, { + return this.http.patch(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, { observe: 'response' }); } deleteNamespace(subsystemNQN: string, nsid: string) { - return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, { + return this.http.delete(`${API_PATH}/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 21969db73c83a..5d60923d00448 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 @@ -20,6 +20,10 @@ export interface NvmeofSubsystem { max_namespaces: number; } +export interface NvmeofSubsystemInitiator { + nqn: string; +} + export interface NvmeofListener { host_name: string; trtype: string; @@ -29,10 +33,6 @@ export interface NvmeofListener { id?: number; // for table } -export interface NvmeofSubsystemHost { - nqn: string; -} - export interface NvmeofSubsystemNamespace { nsid: number; uuid: string; 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 ace06a7709074..c4f57286a3af7 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,9 +367,6 @@ 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) ), @@ -379,6 +376,12 @@ export class TaskMessageService { 'nvmeof/namespace/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.nvmeofNamespace(metadata) ), + 'nvmeof/initiator/add': this.newTaskMessage(this.commonOperations.add, (metadata) => + this.nvmeofInitiator(metadata) + ), + 'nvmeof/initiator/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) => + this.nvmeofInitiator(metadata) + ), // nfs 'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) => this.nfs(metadata) @@ -510,7 +513,7 @@ export class TaskMessageService { } nvmeofListener(metadata: any) { - return $localize`listener '${metadata.host_name} on subsystem ${metadata.nqn}`; + return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`; } nvmeofNamespace(metadata: any) { @@ -520,6 +523,10 @@ export class TaskMessageService { return $localize`namespace for subsystem '${metadata.nqn}'`; } + nvmeofInitiator(metadata: any) { + return $localize`initiator${metadata?.plural ? 's' : ''} for subsystem ${metadata.nqn}`; + } + nfs(metadata: any) { return $localize`NFS '${metadata.cluster_id}\:${ metadata.export_id ? metadata.export_id : metadata.path -- 2.39.5