From: Sagar Gopale Date: Wed, 25 Feb 2026 16:34:13 +0000 (+0530) Subject: mgr/dashboard: NVmeof Add listeners in subsytem and resource page. X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=0290539e739645c5a484b2122cd32d7ecd6f0968;p=ceph.git mgr/dashboard: NVmeof Add listeners in subsytem and resource page. Fixes: https://tracker.ceph.com/issues/74339 Signed-off-by: Sagar Gopale --- 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 3b63c5a2731..825c3b8b3f5 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 @@ -448,6 +448,11 @@ const routes: Routes = [ path: `${URLVerbs.ADD}/initiator`, component: NvmeofInitiatorsFormComponent, outlet: 'modal' + }, + { + path: `${URLVerbs.ADD}/listener`, + component: NvmeofListenersFormComponent, + outlet: 'modal' } ] } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts index 804a2003417..1e1a444e456 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts @@ -8,7 +8,13 @@ import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumb }) export class NvmeSubsystemViewBreadcrumbResolver extends BreadcrumbsResolver { resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { - const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn; - return [{ text: decodeURIComponent(subsystemNQN || ''), path: null }]; + const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn || ''; + let decodedNQN = subsystemNQN; + try { + decodedNQN = decodeURIComponent(subsystemNQN); + } catch (e) { + // Fallback to raw value if decoding fails + } + return [{ text: decodedNQN, path: null }]; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts index f055b715a85..e7c3c196157 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts @@ -1,7 +1,8 @@ -import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { SideNavModule, ThemeModule } from 'carbon-components-angular'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; import { NvmeSubsystemViewComponent } from './nvme-subsystem-view.component'; import { HttpClientTestingModule } from '@angular/common/http/testing'; @@ -10,11 +11,22 @@ describe('NvmeSubsystemViewComponent', () => { let component: NvmeSubsystemViewComponent; let fixture: ComponentFixture; + const mockParamMap = { + get: (key: string) => (key === 'subsystem_nqn' ? 'nqn.test' : null) + }; + const mockQueryParams = { group: 'my-group' }; + + const mockActivatedRoute = { + paramMap: of(mockParamMap), + queryParams: of(mockQueryParams) + }; + beforeEach( waitForAsync(() => { TestBed.configureTestingModule({ declarations: [NvmeSubsystemViewComponent], - imports: [RouterTestingModule, SideNavModule, ThemeModule, HttpClientTestingModule], + imports: [RouterTestingModule, HttpClientTestingModule], + providers: [{ provide: ActivatedRoute, useValue: mockActivatedRoute }], schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); }) @@ -29,4 +41,30 @@ describe('NvmeSubsystemViewComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); + + it('should build sidebar items correctly', () => { + expect(component.sidebarItems.length).toBe(3); + + // Verify first item (Initiators) + expect(component.sidebarItems[0].route).toEqual([ + '/block/nvmeof/subsystems', + 'nqn.test', + 'hosts' + ]); + expect(component.sidebarItems[0].routeExtras).toEqual({ queryParams: { group: 'my-group' } }); + + // Verify second item (Namespaces) + expect(component.sidebarItems[1].route).toEqual([ + '/block/nvmeof/subsystems', + 'nqn.test', + 'namespaces' + ]); + + // Verify third item (Listeners) + expect(component.sidebarItems[2].route).toEqual([ + '/block/nvmeof/subsystems', + 'nqn.test', + 'listeners' + ]); + }); }); 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 index 666e1f50d35..3876795c10f 100644 --- 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 @@ -80,9 +80,8 @@ export class NvmeofInitiatorsFormComponent implements OnInit { call: this.nvmeofService.addInitiators(this.subsystemNQN, request) }) .subscribe({ - error: (err) => { + error: () => { this.isSubmitLoading = false; - err.preventDefault(); }, complete: () => { this.isSubmitLoading = false; 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 index 2c2aff3cc9f..ab2f0d7bfef 100644 --- 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 @@ -6,6 +6,7 @@ import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; import { Icons } from '~/app/shared/enum/icons.enum'; import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { @@ -20,8 +21,6 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { NvmeofEditHostKeyModalComponent } from '../nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component'; -const BASE_URL = 'block/nvmeof/subsystems'; - @Component({ selector: 'cd-nvmeof-initiators-list', templateUrl: './nvmeof-initiators-list.component.html', @@ -37,7 +36,7 @@ export class NvmeofInitiatorsListComponent implements OnInit { @ViewChild('dhchapTpl', { static: true }) dhchapTpl: TemplateRef; - initiatorColumns: any; + initiatorColumns: CdTableColumn[]; tableActions: CdTableAction[]; selection = new CdTableSelection(); permission: Permission; @@ -145,13 +144,6 @@ export class NvmeofInitiatorsListComponent implements OnInit { return this.initiators.some((initiator) => initiator.nqn === '*'); } - editHostAccess() { - this.router.navigate( - [BASE_URL, { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }], - { queryParams: { group: this.group } } - ); - } - updateSelection(selection: CdTableSelection) { this.selection = selection; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.html index 910e5579ee4..c0775f727b3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.html @@ -1,76 +1,19 @@ - - {{ action | titlecase }} {{ resource | upperFirst }} - -
- - -
-
-
+ + + + + + 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 74bad35b13c..24b22fe8276 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 @@ -1,13 +1,14 @@ 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 { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { ToastrModule } from 'ngx-toastr'; -import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofListenersFormComponent } from './nvmeof-listeners-form.component'; +import { ActivatedRoute } from '@angular/router'; +import { of } from 'rxjs'; describe('NvmeofListenersFormComponent', () => { let component: NvmeofListenersFormComponent; @@ -16,20 +17,26 @@ describe('NvmeofListenersFormComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [NvmeofListenersFormComponent], - providers: [NgbActiveModal], - imports: [ - HttpClientTestingModule, - NgbTypeaheadModule, - ReactiveFormsModule, - RouterTestingModule, - SharedModule, - ToastrModule.forRoot() - ] + providers: [ + { + provide: ActivatedRoute, + useValue: { + params: of({ subsystem_nqn: 'nqn.2001-07.com.ceph:1' }), + queryParams: of({ group: 'group1' }), + snapshot: { params: { subsystem_nqn: 'nqn.2001-07.com.ceph:1' } }, + parent: { + snapshot: { params: { subsystem_nqn: 'nqn.2001-07.com.ceph:1' } }, + params: of({ subsystem_nqn: 'nqn.2001-07.com.ceph:1' }) + } + } + } + ], + imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, ToastrModule.forRoot()], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(NvmeofListenersFormComponent); component = fixture.componentInstance; - component.ngOnInit(); fixture.detectChanges(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.ts index 7bf74880c17..cb1f0293ee5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.ts @@ -1,22 +1,10 @@ -import _ from 'lodash'; 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 { GatewayGroup, ListenerRequest, 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 { Step } from 'carbon-components-angular'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { FinishedTask } from '~/app/shared/models/finished-task'; -import { Permission } from '~/app/shared/models/permissions'; -import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { ListenerItem } from '~/app/shared/models/nvmeof'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; -import { FormatterService } from '~/app/shared/services/formatter.service'; -import { CdValidators } from '~/app/shared/forms/cd-validators'; -import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; -import { HostService } from '~/app/shared/api/host.service'; -import { map } from 'rxjs/operators'; -import { forkJoin } from 'rxjs'; -import { Host } from '~/app/shared/models/host.interface'; +import { ActivatedRoute, Router } from '@angular/router'; @Component({ selector: 'cd-nvmeof-listeners-form', @@ -25,129 +13,61 @@ import { Host } from '~/app/shared/models/host.interface'; standalone: false }) export class NvmeofListenersFormComponent implements OnInit { - action: string; - permission: Permission; - hostPermission: Permission; - resource: string; - pageURL: string; - listenerForm: CdFormGroup; - subsystemNQN: string; - hosts: Array = null; - group: string; + group!: string; + subsystemNQN!: string; + isSubmitLoading = false; + + steps: Step[] = [ + { + label: $localize`Listeners`, + invalid: false + } + ]; + + title = $localize`Add Listener`; + description = $localize`Listeners determine where and how hosts can connect to the subsystem over the network.`; constructor( - public actionLabels: ActionLabelsI18n, - private authStorageService: AuthStorageService, - private taskWrapperService: TaskWrapperService, private nvmeofService: NvmeofService, - private hostService: HostService, + private taskWrapperService: TaskWrapperService, private router: Router, - private route: ActivatedRoute, - public activeModal: NgbActiveModal, - public formatterService: FormatterService, - public dimlessBinaryPipe: DimlessBinaryPipe - ) { - this.permission = this.authStorageService.getPermissions().nvmeof; - this.hostPermission = this.authStorageService.getPermissions().hosts; - this.resource = $localize`Listener`; - this.pageURL = 'block/nvmeof/subsystems'; - } - - filterHostsByLabel(allHosts: Host[], gwNodesLabel: string | string[]) { - return allHosts.filter((host: Host) => { - const hostLabels: string[] = host?.labels; - if (typeof gwNodesLabel === 'string') { - return hostLabels.includes(gwNodesLabel); - } - return hostLabels?.length === gwNodesLabel?.length && _.isEqual(hostLabels, gwNodesLabel); - }); - } - - filterHostsByHostname(allHosts: Host[], gwNodes: string[]) { - return allHosts.filter((host: Host) => gwNodes.includes(host.hostname)); - } - - getGwGroupPlacement(gwGroups: GatewayGroup[][]) { - return ( - gwGroups?.[0]?.find((gwGroup: GatewayGroup) => gwGroup?.spec?.group === this.group) - ?.placement || { hosts: [], label: [] } - ); - } - - setHosts() { - forkJoin({ - gwGroups: this.nvmeofService.listGatewayGroups(), - allHosts: this.hostService.getAllHosts() - }) - .pipe( - map(({ gwGroups, allHosts }) => { - const { hosts, label } = this.getGwGroupPlacement(gwGroups); - if (hosts?.length) return this.filterHostsByHostname(allHosts, hosts); - else if (label?.length) return this.filterHostsByLabel(allHosts, label); - return []; - }) - ) - .subscribe((nvmeofGwNodes: Host[]) => { - this.hosts = nvmeofGwNodes.map((h) => ({ hostname: h.hostname, addr: h.addr })); - }); - } + private route: ActivatedRoute + ) {} ngOnInit() { - this.createForm(); - this.action = this.actionLabels.CREATE; - this.route.params.subscribe((params: { subsystem_nqn: string }) => { - this.subsystemNQN = params?.subsystem_nqn; - }); this.route.queryParams.subscribe((params) => { this.group = params?.['group']; }); - this.setHosts(); + // subsystem_nqn can be in route.params (create/:subsystem_nqn/listener) + // or route.parent.params (subsystems/:subsystem_nqn > add/listener) + const params = this.route.snapshot.params; + const parentParams = this.route.parent?.snapshot.params; + this.subsystemNQN = params?.['subsystem_nqn'] || parentParams?.['subsystem_nqn']; } - createForm() { - this.listenerForm = new CdFormGroup({ - host: new UntypedFormControl(null, { - validators: [Validators.required] - }), - trsvcid: new UntypedFormControl(4420, [ - Validators.required, - CdValidators.number(false), - Validators.max(65535) - ]) - }); - } - - buildRequest(): ListenerRequest { - const host = this.listenerForm.getValue('host'); - let trsvcid = Number(this.listenerForm.getValue('trsvcid')); - if (!trsvcid) trsvcid = 4420; - const request: ListenerRequest = { - gw_group: this.group, - host_name: host.hostname, - traddr: host.addr, - trsvcid - }; - return request; - } + onSubmit(payload: { listeners: ListenerItem[] }) { + if (!payload.listeners || payload.listeners.length === 0) { + return; + } + this.isSubmitLoading = true; + const taskUrl = `nvmeof/listener/add`; - onSubmit() { - const component = this; - const taskUrl: string = `nvmeof/listener/${URLVerbs.CREATE}`; - const request = this.buildRequest(); this.taskWrapperService .wrapTaskAroundCall({ task: new FinishedTask(taskUrl, { nqn: this.subsystemNQN, - host_name: request.host_name + count: payload.listeners.length }), - call: this.nvmeofService.createListener(this.subsystemNQN, request) + call: this.nvmeofService.createListeners(this.subsystemNQN, this.group, payload.listeners) }) .subscribe({ - error() { - component.listenerForm.setErrors({ cdSubmitButton: true }); + error: () => { + this.isSubmitLoading = false; }, complete: () => { - this.router.navigate([this.pageURL, { outlets: { modal: null } }], { + this.isSubmitLoading = false; + this.router.navigate([{ outlets: { modal: null } }], { + relativeTo: this.route.parent, queryParamsHandling: 'preserve' }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.html index d806ec54025..368d6585065 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.html @@ -14,6 +14,11 @@ [autoReload]="true" forceIdentifier="true" selectionType="single" + emptyStateTitle="No listener found." + i18n-emptyStateTitle + emptyStateMessage="No listeners found. Add listeners to define network endpoints for hosts" + i18n-emptyStateMessage + [emptyStateIcon]="iconType.emptySearch" (updateSelection)="updateSelection($event)">
- this.router.navigate( - [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'listener'] } }], - { queryParams: { group: this.group } } - ), - canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'listener'] } }], { + queryParams: { group: this.group }, + relativeTo: this.route.parent + }), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, + disable: () => + !this.hasAvailableNodes ? $localize`All available nodes already have listeners` : false }, { name: this.actionLabels.DELETE, @@ -116,9 +120,23 @@ export class NvmeofListenersListComponent implements OnInit { listener['full_addr'] = `${listener.traddr}:${listener.trsvcid}`; return listener; }); + this.checkAvailableNodes(); }); } + checkAvailableNodes() { + if (!this.group) return; + this.nvmeofService.getHostsForGroup(this.group).subscribe({ + next: (allHosts: Host[]) => { + const listenerHostNames = new Set((this.listeners || []).map((l) => l.host_name)); + this.hasAvailableNodes = allHosts.some((h) => !listenerHostNames.has(h.hostname)); + }, + error: () => { + this.hasAvailableNodes = true; + } + }); + } + deleteListenerModal() { const listener = this.selection.first(); this.modalService.show(DeleteConfirmationModalComponent, { 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 index 2a185720eeb..d71e41046b8 100644 --- 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 @@ -207,8 +207,7 @@ export class NvmeofNamespacesFormComponent implements OnInit { validators: [Validators.required] }), image_size: new UntypedFormControl(null, { - validators: [Validators.required], - updateOn: 'blur' + validators: [Validators.required] }), nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [ Validators.required, @@ -282,6 +281,15 @@ export class NvmeofNamespacesFormComponent implements OnInit { return Math.random().toString(36).substring(2); } + private normalizeImageSizeInput(value: string): string { + const input = (value || '').trim(); + if (!input) { + return input; + } + // Accept plain numeric values as GiB (e.g. "45" => "45GiB"). + return /^\d+(\.\d+)?$/.test(input) ? `${input}GiB` : input; + } + buildCreateRequest( rbdImageSize: number, nsCount: number, @@ -344,7 +352,13 @@ export class NvmeofNamespacesFormComponent implements OnInit { let rbdImageSize: number = null; if (image_size) { - rbdImageSize = this.formatterService.toBytes(image_size); + const normalizedSize = this.normalizeImageSizeInput(image_size); + rbdImageSize = this.formatterService.toBytes(normalizedSize); + if (rbdImageSize === null) { + this.nsForm.get('image_size').setErrors({ invalid: true }); + this.nsForm.setErrors({ cdSubmitButton: true }); + return; + } } const subsystemNQN = this.nsForm.getValue('subsystem'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts index a21879a5d87..68927708547 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts @@ -88,4 +88,9 @@ describe('NvmeofSubsystemNamespacesListComponent', () => { expect(component.namespaces.length).toEqual(2); expect(component.namespaces[0].nsid).toEqual(1); })); + it('should have table actions defined', () => { + component.ngOnInit(); + expect(component.tableActions).toBeDefined(); + expect(component.tableActions.length).toBeGreaterThan(0); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html index a8c179ef949..a811c593088 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html @@ -7,14 +7,16 @@ [fullWidth]="true">
+ @if (!listenersOnly) {

Subsystem details

-

Enter identifying and network details for this subsystem.

+

Enter identifying and network details for this subsystem.

+ } + @if (!listenersOnly) {
@@ -47,12 +52,50 @@ [skeleton]="!group">
+ } +
+ + + + @if (formGroup.get('listeners').value?.length) { +
+ @for (listener of formGroup.get('listeners').value; track listener.content; let i = $index) { + + {{ listener.content }} + + } +
+ } + + Listeners + + + Select listeners for this subsystem. + + + This field is required. + +
+@if (!listenersOnly) { @for (err of formGroup.get('nqn').errors | keyvalue; track err.key) { {{ INVALID_TEXTS[err.key] }} } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss index e69de29bb2d..1afbc9ce340 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss @@ -0,0 +1 @@ +// Custom SCSS removed as selectionTemplate is now supported. diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts index 5e4c78405de..b1ab55127f1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts @@ -12,12 +12,16 @@ import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1.component'; import { FormHelper } from '~/testing/unit-test-helper'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; -import { GridModule, InputModule } from 'carbon-components-angular'; +import { ComboBoxModule, GridModule, InputModule } from 'carbon-components-angular'; + +import { of } from 'rxjs'; describe('NvmeofSubsystemsStepOneComponent', () => { let component: NvmeofSubsystemsStepOneComponent; let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + let form: CdFormGroup; let formHelper: FormHelper; const mockGroupName = 'default'; @@ -25,21 +29,27 @@ describe('NvmeofSubsystemsStepOneComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [NvmeofSubsystemsStepOneComponent], - providers: [NgbActiveModal], imports: [ HttpClientTestingModule, - NgbTypeaheadModule, + SharedModule, ReactiveFormsModule, RouterTestingModule, - SharedModule, + NgbTypeaheadModule, InputModule, GridModule, + ComboBoxModule, ToastrModule.forRoot() - ] + ], + providers: [NgbActiveModal] }).compileComponents(); + }); + beforeEach(() => { fixture = TestBed.createComponent(NvmeofSubsystemsStepOneComponent); component = fixture.componentInstance; + + nvmeofService = TestBed.inject(NvmeofService); + spyOn(nvmeofService, 'getHostsForGroup').and.returnValue(of([])); component.ngOnInit(); form = component.formGroup; formHelper = new FormHelper(form); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts index 94cd4442ec1..866717ef4a5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts @@ -1,4 +1,5 @@ import { Component, Input, OnInit } from '@angular/core'; +import { forkJoin, of } from 'rxjs'; import { UntypedFormControl, Validators } from '@angular/forms'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; @@ -8,6 +9,8 @@ import { CdValidators } from '~/app/shared/forms/cd-validators'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { TearsheetStep } from '~/app/shared/models/tearsheet-step'; +import { Host } from '~/app/shared/models/host.interface'; +import { ListenerItem } from '~/app/shared/models/nvmeof'; @Component({ selector: 'cd-nvmeof-subsystem-step-one', @@ -17,6 +20,8 @@ import { TearsheetStep } from '~/app/shared/models/tearsheet-step'; }) export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { @Input() group!: string; + @Input() subsystemNQN: string; + @Input() listenersOnly = false; formGroup: CdFormGroup; action: string; pageURL: string; @@ -27,6 +32,8 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { maxLength: $localize`An NQN may not be more than 223 bytes in length.` }; + hosts: ListenerItem[] = []; + constructor( public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal, @@ -45,29 +52,63 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep { ngOnInit() { this.createForm(); + this.setHosts(); + } + + setHosts() { + const hosts$ = this.nvmeofService.getHostsForGroup(this.group); + const listeners$ = + this.listenersOnly && this.subsystemNQN + ? this.nvmeofService.listListeners(this.subsystemNQN, this.group) + : of(null); + + forkJoin([hosts$, listeners$]).subscribe( + ([nvmeofGwNodes, existingListeners]: [Host[], any]) => { + const listeners = Array.isArray(existingListeners) + ? existingListeners + : existingListeners?.listeners || []; + const consumedHosts = new Set(listeners.map((l: any) => l.host_name)); + this.hosts = nvmeofGwNodes + .map((h) => ({ content: h.hostname, addr: h.addr })) + .filter((h) => !consumedHosts.has(h.content)); + } + ); } createForm() { - this.formGroup = new CdFormGroup({ - nqn: new UntypedFormControl(this.DEFAULT_NQN, { - validators: [ - this.customNQNValidator, - Validators.required, - CdValidators.custom( - 'maxLength', - (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223 - ) - ], - asyncValidators: [ - CdValidators.unique( - this.nvmeofService.isSubsystemPresent, - this.nvmeofService, - null, - null, - this.group - ) - ] - }) - }); + if (this.listenersOnly) { + this.formGroup = new CdFormGroup({ + listeners: new UntypedFormControl([]) + }); + } else { + this.formGroup = new CdFormGroup({ + nqn: new UntypedFormControl(this.DEFAULT_NQN, { + validators: [ + this.customNQNValidator, + Validators.required, + CdValidators.custom( + 'maxLength', + (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223 + ) + ], + asyncValidators: [ + CdValidators.unique( + this.nvmeofService.isSubsystemPresent, + this.nvmeofService, + null, + null, + this.group + ) + ] + }), + listeners: new UntypedFormControl([]) + }); + } + } + + removeListener(index: number) { + const listeners = this.formGroup.get('listeners').value; + listeners.splice(index, 1); + this.formGroup.get('listeners').setValue([...listeners]); } } 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 4c7e3d6c3f0..cc7fd4a969e 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 @@ -1,5 +1,6 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ReactiveFormsModule } from '@angular/forms'; +import { ActivatedRoute } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; @@ -14,7 +15,13 @@ import { } from './nvmeof-subsystems-form.component'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component'; -import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular'; +import { + ComboBoxModule, + GridModule, + InputModule, + RadioModule, + TagModule +} from 'carbon-components-angular'; import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component'; import { HOST_TYPE } from '~/app/shared/models/nvmeof'; import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component'; @@ -31,7 +38,8 @@ describe('NvmeofSubsystemsFormComponent', () => { gw_group: mockGroupName, subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=', addedHosts: [], - hostType: HOST_TYPE.ALL + hostType: HOST_TYPE.ALL, + listeners: [] }; beforeEach(async () => { @@ -43,7 +51,15 @@ describe('NvmeofSubsystemsFormComponent', () => { NvmeofSubsystemsStepThreeComponent, NvmeofSubsystemsStepTwoComponent ], - providers: [NgbActiveModal], + providers: [ + NgbActiveModal, + { + provide: ActivatedRoute, + useValue: { + queryParams: of({ group: mockGroupName }) + } + } + ], imports: [ HttpClientTestingModule, NgbTypeaheadModule, @@ -54,7 +70,8 @@ describe('NvmeofSubsystemsFormComponent', () => { GridModule, RadioModule, TagModule, - ToastrModule.forRoot() + ToastrModule.forRoot(), + ComboBoxModule ] }).compileComponents(); @@ -62,7 +79,6 @@ describe('NvmeofSubsystemsFormComponent', () => { component = fixture.componentInstance; component.ngOnInit(); fixture.detectChanges(); - component.group = mockGroupName; }); it('should create', () => { @@ -94,7 +110,8 @@ describe('NvmeofSubsystemsFormComponent', () => { gw_group: mockGroupName, addedHosts: [], hostType: HOST_TYPE.ALL, - subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=' + subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=', + listeners: [] }; component.group = mockGroupName; 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 4012f8ec4a7..0f253f99727 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 @@ -3,12 +3,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; -import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; import { ActivatedRoute, Router } from '@angular/router'; import { Step } from 'carbon-components-angular'; import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service'; import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; -import { HOST_TYPE } from '~/app/shared/models/nvmeof'; +import { HOST_TYPE, ListenerItem } from '~/app/shared/models/nvmeof'; import { from, Observable, of } from 'rxjs'; import { NotificationService } from '~/app/shared/services/notification.service'; import { NotificationType } from '~/app/shared/enum/notification-type.enum'; @@ -21,6 +20,7 @@ export type SubsystemPayload = { subsystemDchapKey: string; addedHosts: string[]; hostType: string; + listeners: ListenerItem[]; }; type StepResult = { step: string; success: boolean; error?: string }; @@ -32,7 +32,6 @@ type StepResult = { step: string; success: boolean; error?: string }; standalone: false }) export class NvmeofSubsystemsFormComponent implements OnInit { - subsystemForm: CdFormGroup; action: string; group: string; steps: Step[] = [ @@ -91,16 +90,27 @@ export class NvmeofSubsystemsFormComponent implements OnInit { .subscribe({ next: () => { stepResults.push({ step: this.steps[0].label, success: true }); - this.runSequentialSteps( - [ - { - step: this.steps[1].label, - call: () => - this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest) - } - ], - stepResults - ).subscribe({ + const sequentialSteps: { step: string; call: () => Observable }[] = []; + + if (payload.listeners && payload.listeners.length > 0) { + sequentialSteps.push({ + step: $localize`Listeners`, + call: () => + this.nvmeofService.createListeners( + `${payload.nqn}.${this.group}`, + this.group, + payload.listeners + ) + }); + } + + sequentialSteps.push({ + step: this.steps[1].label, + call: () => + this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest) + }); + + this.runSequentialSteps(sequentialSteps, stepResults).subscribe({ complete: () => this.showFinalNotification(stepResults) }); }, 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 91bdf4e9bf7..d3efcb3faca 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 @@ -1,11 +1,4 @@ -import { - Component, - OnDestroy, - OnInit, - TemplateRef, - ViewChild, - ChangeDetectorRef -} from '@angular/core'; +import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; @@ -82,8 +75,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit private router: Router, private route: ActivatedRoute, private modalService: ModalCdsService, - private taskWrapper: TaskWrapperService, - private cdRef: ChangeDetectorRef + private taskWrapper: TaskWrapperService ) { super(); this.permissions = this.authStorageService.getPermissions(); @@ -161,7 +153,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit }), tap((subs) => { this.subsystems = subs; - this.expandPendingSubsystem(); }), takeUntil(this.destroy$) ); @@ -260,19 +251,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit this.context?.error?.(error); } - private expandPendingSubsystem() { - if (!this.pendingNqn) return; - const match = this.subsystems.find((s) => s.nqn === this.pendingNqn); - if (match && this.table) { - setTimeout(() => { - this.table.expanded = match; - this.table.toggleExpandRow(); - this.cdRef.detectChanges(); - }); - } - this.pendingNqn = null; - } - private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) { return this.nvmeofService.getInitiators(sub.nqn, this.group).pipe( catchError(() => of([])), 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 ede904c2f64..37e8932c365 100755 --- 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 @@ -167,7 +167,7 @@ describe('NvmeofService', () => { enable_ha: true, initiators: '*', gw_group: mockGroupName, - dhchap_key: '' + dhchap_key: null }; service.createSubsystem(request).subscribe(); const req = httpTesting.expectOne(`${API_PATH}/subsystem`); @@ -247,9 +247,7 @@ describe('NvmeofService', () => { const mockNsid = '1'; it('should call listNamespaces', () => { service.listNamespaces(mockGroupName).subscribe(); - const req = httpTesting.expectOne( - `${API_PATH}/subsystem/*/namespace?gw_group=${mockGroupName}` - ); + const req = httpTesting.expectOne(`${API_PATH}/subsystem/*/namespace?gw_group=${mockGroupName}`); expect(req.request.method).toBe('GET'); }); it('should call getNamespace', () => { @@ -285,4 +283,134 @@ describe('NvmeofService', () => { expect(req.request.method).toBe('DELETE'); }); }); + + describe('getHostsForGroup', () => { + const allHosts = [ + { hostname: 'host1', labels: ['nvmeof'], status: '' }, + { hostname: 'host2', labels: ['storage'], status: '' }, + { hostname: 'host3', labels: ['nvmeof', 'storage'], status: '' } + ]; + + it('should filter hosts by direct host placement', (done) => { + const mockGroups = [ + [{ spec: { group: 'default' }, placement: { hosts: ['host1', 'host3'], label: [] } }] + ]; + mockHostService.getAllHosts.mockReturnValue(of(allHosts)); + + service.getHostsForGroup('default').subscribe((hosts: any[]) => { + expect(hosts.length).toBe(2); + expect(hosts.map((h: any) => h.hostname)).toEqual(['host1', 'host3']); + done(); + }); + + const req = httpTesting.expectOne(`${API_PATH}/gateway/group`); + req.flush(mockGroups); + }); + + it('should filter hosts by string label placement', (done) => { + const mockGroups = [ + [{ spec: { group: 'default' }, placement: { hosts: [], label: 'nvmeof' } }] + ]; + mockHostService.getAllHosts.mockReturnValue(of(allHosts)); + + service.getHostsForGroup('default').subscribe((hosts: any[]) => { + expect(hosts.length).toBe(2); + expect(hosts.map((h: any) => h.hostname)).toEqual(['host1', 'host3']); + done(); + }); + + const req = httpTesting.expectOne(`${API_PATH}/gateway/group`); + req.flush(mockGroups); + }); + + it('should filter hosts by array label placement', (done) => { + const mockGroups = [ + [{ spec: { group: 'default' }, placement: { hosts: [], label: ['nvmeof', 'storage'] } }] + ]; + mockHostService.getAllHosts.mockReturnValue(of(allHosts)); + + service.getHostsForGroup('default').subscribe((hosts: any[]) => { + expect(hosts.length).toBe(1); + expect(hosts[0].hostname).toBe('host3'); + done(); + }); + + const req = httpTesting.expectOne(`${API_PATH}/gateway/group`); + req.flush(mockGroups); + }); + + it('should return empty array when group not found', (done) => { + const mockGroups = [ + [{ spec: { group: 'other' }, placement: { hosts: ['host1'], label: [] } }] + ]; + mockHostService.getAllHosts.mockReturnValue(of(allHosts)); + + service.getHostsForGroup('non-existent').subscribe((hosts: any[]) => { + expect(hosts.length).toBe(0); + done(); + }); + + const req = httpTesting.expectOne(`${API_PATH}/gateway/group`); + req.flush(mockGroups); + }); + + it('should return empty array when placement has no hosts or labels', (done) => { + const mockGroups = [[{ spec: { group: 'default' }, placement: { hosts: [], label: [] } }]]; + mockHostService.getAllHosts.mockReturnValue(of(allHosts)); + + service.getHostsForGroup('default').subscribe((hosts: any[]) => { + expect(hosts.length).toBe(0); + done(); + }); + + const req = httpTesting.expectOne(`${API_PATH}/gateway/group`); + req.flush(mockGroups); + }); + }); + + describe('createListeners', () => { + it('should call createListener for each listener in the array', () => { + const listeners = [ + { content: 'ceph-node-01', addr: '192.168.1.1' }, + { content: 'ceph-node-02', addr: '192.168.1.2' } + ]; + + service.createListeners('nqn.test', 'default', listeners).subscribe(); + + const reqs = httpTesting.match(`${API_PATH}/subsystem/nqn.test/listener`); + expect(reqs.length).toBe(2); + expect(reqs[0].request.method).toBe('POST'); + expect(reqs[0].request.body).toEqual({ + gw_group: 'default', + host_name: 'ceph-node-01', + traddr: '192.168.1.1', + trsvcid: 4420 + }); + expect(reqs[1].request.body).toEqual({ + gw_group: 'default', + host_name: 'ceph-node-02', + traddr: '192.168.1.2', + trsvcid: 4420 + }); + + reqs.forEach((req) => req.flush({}, { status: 200, statusText: 'OK' })); + }); + + it('should call createListener for a single listener', () => { + const listeners = [{ content: 'ceph-node-01', addr: '192.168.1.1' }]; + + service.createListeners('nqn.test', 'group1', listeners).subscribe(); + + const reqs = httpTesting.match(`${API_PATH}/subsystem/nqn.test/listener`); + expect(reqs.length).toBe(1); + expect(reqs[0].request.body).toEqual({ + gw_group: 'group1', + host_name: 'ceph-node-01', + traddr: '192.168.1.1', + trsvcid: 4420 + }); + + reqs[0].flush({}, { status: 200, statusText: 'OK' }); + }); + }); }); 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 12641a0f278..f5833f3ca3e 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 @@ -4,8 +4,8 @@ import { HttpClient } from '@angular/common/http'; import _ from 'lodash'; import { Observable, forkJoin, of as observableOf } from 'rxjs'; import { catchError, map, mapTo, mergeMap } from 'rxjs/operators'; -import { NvmeofSubsystemNamespace } from '../models/nvmeof'; import { CephServiceSpec } from '../models/service.interface'; +import { ListenerItem } from '../models/nvmeof'; import { HostService } from './host.service'; import { OrchestratorService } from './orchestrator.service'; import { HostStatus } from '../enum/host-status.enum'; @@ -37,8 +37,8 @@ export type NamespaceCreateRequest = NvmeofRequest & { rbd_pool: string; rbd_image_size?: number; no_auto_visible?: boolean; - create_image: boolean; block_size?: number; + create_image: boolean; }; export type NamespaceUpdateRequest = NvmeofRequest & { @@ -111,6 +111,35 @@ export class NvmeofService { }); } + getHostsForGroup(groupName: string): Observable { + return forkJoin({ + gwGroups: this.listGatewayGroups(), + allHosts: this.hostService.getAllHosts() + }).pipe( + map(({ gwGroups, allHosts }) => { + const group = gwGroups?.[0]?.find( + (gwGroup: CephServiceSpec) => gwGroup?.spec?.group === groupName + ); + const placement = group?.placement || { hosts: [], label: [] }; + const { hosts, label } = placement; + + if (hosts?.length) { + return allHosts.filter((host: Host) => hosts.includes(host.hostname)); + } else if (label?.length) { + if (typeof label === 'string') { + return allHosts.filter((host: Host) => host?.labels?.includes(label)); + } + return allHosts.filter( + (host: Host) => + host?.labels?.length === label?.length && + _.isEqual([...host.labels].sort(), [...label].sort()) + ); + } + return []; + }) + ); + } + // formats the gateway groups to be consumed for combobox item formatGwGroupsList( data: CephServiceSpec[][], @@ -188,6 +217,16 @@ export class NvmeofService { }); } + addNamespaceInitiators(nsid: number | string, request: NamespaceInitiatorRequest) { + return this.http.post( + `${UI_API_PATH}/subsystem/${request.subsystem_nqn}/namespace/${nsid}/host`, + request, + { + observe: 'response' + } + ); + } + updateHostKey(subsystemNQN: string, request: InitiatorRequest) { return this.http.put( `${API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/change_key`, @@ -198,12 +237,6 @@ export class NvmeofService { ); } - addNamespaceInitiators(nsid: string, request: NamespaceInitiatorRequest) { - return this.http.post(`${UI_API_PATH}/namespace/${nsid}/host`, request, { - observe: 'response' - }); - } - removeInitiators(subsystemNQN: string, request: InitiatorRequest) { return this.http.delete( `${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/${request.gw_group}`, @@ -224,6 +257,18 @@ export class NvmeofService { }); } + createListeners(subsystemNQN: string, gwGroup: string, listeners: ListenerItem[]) { + const listenerCalls = listeners.map((listener: ListenerItem) => + this.createListener(subsystemNQN, { + gw_group: gwGroup, + host_name: listener.content, + traddr: listener.addr, + trsvcid: 4420 + }) + ); + return forkJoin(listenerCalls); + } + deleteListener( subsystemNQN: string, group: string, @@ -243,11 +288,6 @@ export class NvmeofService { } ); } - listSubsystemNamespaces(subsystemNQN: string) { - return this.http.get( - `${API_PATH}/subsystem/${subsystemNQN}/namespace` - ); - } // Namespaces listNamespaces(group: string, subsystemNQN: string = '*') { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html index 1c4f562d147..76dcec8cb1e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html @@ -78,7 +78,7 @@ @else { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss index bbcd84b1701..b2157fe3b63 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss @@ -20,6 +20,16 @@ flex-direction: column; } +:host ::ng-deep .cds--modal-container.cds--modal-container--sm { + inset-block-start: 88px; + block-size: 50vh; + max-block-size: 50vh; + inset-inline-start: 35%; + inset-inline-end: 35%; + inline-size: auto; + max-inline-size: none; +} + // FULL TEARSHEET .tearsheet--full { height: 100%; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts index 71419dd2664..8f416ccb2a5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts @@ -61,6 +61,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { @Input() steps!: Array; @Input() description!: string; @Input() type: 'full' | 'wide' = 'wide'; + @Input() size: 'xs' | 'sm' | 'md' | 'lg' = 'lg'; @Input() submitButtonLabel: string = $localize`Create`; @Input() submitButtonLoadingLabel: string = $localize`Creating`; @Input() isSubmitLoading: boolean = true; 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 26d17b81d6b..36061c628f8 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 @@ -59,8 +59,7 @@ export interface NvmeofSubsystemNamespace { rw_mbytes_per_second: number | string; r_mbytes_per_second: number | string; w_mbytes_per_second: number | string; - ns_subsystem_nqn?: string; // Field from JSON - subsystem_nqn?: string; // Keep for compatibility if needed, but JSON has ns_subsystem_nqn + subsystem_nqn?: string; // Field from JSON (mapped from ns_subsystem_nqn if needed) } export interface NvmeofGatewayGroup extends CephServiceSpec { @@ -83,6 +82,11 @@ export const HOST_TYPE = { SPECIFIC: 'specific' }; +export interface ListenerItem { + content: string; + addr: string; +} + /** * Determines the authentication status of a subsystem based on PSK and initiators. * Can be reused across subsystem pages. 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 bd4ddc5f85f..db8228f0f72 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 @@ -394,6 +394,9 @@ export class TaskMessageService { 'nvmeof/listener/create': this.newTaskMessage(this.commonOperations.create, (metadata) => this.nvmeofListener(metadata) ), + 'nvmeof/listener/add': this.newTaskMessage(this.commonOperations.add, (metadata) => + this.nvmeofListenerPlural(metadata) + ), 'nvmeof/listener/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.nvmeofListener(metadata) ), @@ -608,7 +611,11 @@ export class TaskMessageService { return $localize`hosts to gateway group '${metadata.group_name}'`; } nvmeofListener(metadata: any) { - return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`; + return $localize`listener '${metadata.host_name}' for subsystem ${metadata.nqn}`; + } + + nvmeofListenerPlural(metadata: { count: number; nqn: string }) { + return $localize`${this.pluralize('listener', metadata.count)} to subsystem ${metadata.nqn}`; } nvmeofNamespace(metadata: { nqn: string; nsCount?: number; nsid?: string }) {