From faec1b0a2586c5426250e970d52ffa4db2adb499 Mon Sep 17 00:00:00 2001 From: Sagar Gopale Date: Mon, 19 Jan 2026 17:12:23 +0530 Subject: [PATCH] mgr/dashboard: add-gateway-nodes Fixes: https://tracker.ceph.com/issues/74335 Signed-off-by: Sagar Gopale :wq --- .../src/app/ceph/block/block.module.ts | 10 +- ...meof-gateway-node-add-modal.component.html | 90 +++++++++ ...meof-gateway-node-add-modal.component.scss | 7 + ...f-gateway-node-add-modal.component.spec.ts | 99 ++++++++++ ...nvmeof-gateway-node-add-modal.component.ts | 185 ++++++++++++++++++ .../nvmeof-gateway-node.component.html | 23 ++- .../nvmeof-gateway-node.component.spec.ts | 122 ++---------- .../nvmeof-gateway-node.component.ts | 169 ++++++++-------- .../src/app/shared/api/nvmeof.service.spec.ts | 65 +++++- .../src/app/shared/api/nvmeof.service.ts | 53 ++++- .../app/shared/models/service.interface.ts | 3 + .../shared/services/task-message.service.ts | 7 + 12 files changed, 626 insertions(+), 207 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index bc3a68c36be..9d90cba8636 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 @@ -75,7 +75,8 @@ import { LayoutModule, ContainedListModule, LayerModule, - ThemeModule + ThemeModule, + LayoutModule } from 'carbon-components-angular'; // Icons @@ -91,6 +92,7 @@ import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvme import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component'; import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver'; import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; +import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component'; @NgModule({ imports: [ @@ -124,7 +126,8 @@ import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; LayoutModule, ContainedListModule, SideNavModule, - ThemeModule + ThemeModule, + LayoutModule ], declarations: [ RbdListComponent, @@ -168,7 +171,8 @@ import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; NvmeofSubsystemsStepTwoComponent, NvmeofSubsystemsStepThreeComponent, NvmeGatewayViewComponent, - NvmeofGatewaySubsystemComponent + NvmeofGatewaySubsystemComponent, + NvmeofGatewayNodeAddModalComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html new file mode 100644 index 00000000000..7e88c892b8d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html @@ -0,0 +1,90 @@ + + +

+ Add gateway nodes +

+

+ Select NVMe-oF gateway nodes to associate with this gateway group. +

+
+
+ +
+
+
+ Select gateway nodes +
+
+ Nodes to run NVMe-oF target pods/services +
+
+ + + + + + {{ value || '-' }} + + + +
+ @if (value === HostStatus.AVAILABLE) { + + } + {{ value | titlecase }} +
+
+ + + @if (value && value.length > 0) { + {{ label }} + } @else { + - + } + + +
+
+ + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss new file mode 100644 index 00000000000..dbb535acfd3 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss @@ -0,0 +1,7 @@ +cds-modal-header { + border-bottom: 1px solid var(--cds-ui-03, #e0e0e0); +} + +.status-cell { + align-items: center; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.spec.ts new file mode 100644 index 00000000000..f154d769a89 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.spec.ts @@ -0,0 +1,99 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node-add-modal.component'; +import { SharedModule } from '~/app/shared/shared.module'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HostService } from '~/app/shared/api/host.service'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { CephServiceService } from '~/app/shared/api/ceph-service.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { TaskMessageService } from '~/app/shared/services/task-message.service'; +import { of } from 'rxjs'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; + +describe('NvmeofGatewayNodeAddModalComponent', () => { + let component: NvmeofGatewayNodeAddModalComponent; + let fixture: ComponentFixture; + + const mockHostService = { + checkHostsFactsAvailable: jest.fn().mockReturnValue(true), + list: jest.fn().mockReturnValue(of([{ hostname: 'host1' }, { hostname: 'host2' }])) + }; + + const mockNvmeofService = { + getAvailableHosts: jest.fn().mockReturnValue(of([{ hostname: 'host2' }])) + }; + + const mockCephServiceService = { + update: jest.fn().mockReturnValue(of({})) + }; + + const mockNotificationService = { + show: jest.fn() + }; + + const mockTaskMessageService = { + messages: { + 'nvmeof/gateway/node/add': { + success: jest.fn().mockReturnValue('Success'), + failure: jest.fn().mockReturnValue('Failure') + } + } + }; + + const mockServiceSpec = { + placement: { + hosts: ['host1'] + } + }; + + configureTestBed({ + imports: [SharedModule, HttpClientTestingModule, RouterTestingModule], + declarations: [NvmeofGatewayNodeAddModalComponent], + providers: [ + { provide: HostService, useValue: mockHostService }, + { provide: NvmeofService, useValue: mockNvmeofService }, + { provide: CephServiceService, useValue: mockCephServiceService }, + { provide: NotificationService, useValue: mockNotificationService }, + { provide: TaskMessageService, useValue: mockTaskMessageService }, + { provide: 'groupName', useValue: 'group1' }, + { provide: 'usedHostnames', useValue: ['host1'] }, + { provide: 'serviceSpec', useValue: mockServiceSpec } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NvmeofGatewayNodeAddModalComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should load attributes', () => { + expect(component.groupName).toBe('group1'); + expect(component.usedHostnames).toEqual(['host1']); + expect(component.serviceSpec).toEqual(mockServiceSpec); + }); + + it('should load hosts using NvmeofService', () => { + const context = new CdTableFetchDataContext(() => undefined); + component.getHosts(context); + + expect(mockNvmeofService.getAvailableHosts).toHaveBeenCalled(); + expect(component.hosts.length).toBe(1); + expect(component.hosts[0].hostname).toBe('host2'); + }); + + it('should add gateway node', () => { + component.selection.selected = [{ hostname: 'host2' }]; + component.onSubmit(); + + expect(mockCephServiceService.update).toHaveBeenCalledWith({ + placement: { hosts: ['host1', 'host2'] } + }); + expect(mockNotificationService.show).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts new file mode 100644 index 00000000000..93d856dd8e1 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts @@ -0,0 +1,185 @@ +import { + Component, + EventEmitter, + OnInit, + ViewChild, + TemplateRef, + OnDestroy, + Inject +} from '@angular/core'; +import { Subscription } from 'rxjs'; + +import { CdForm } from '~/app/shared/forms/cd-form'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { Host } from '~/app/shared/models/host.interface'; +import { HostStatus } from '~/app/shared/enum/host-status.enum'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { CephServiceService } from '~/app/shared/api/ceph-service.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { CephServiceSpec, CephServiceSpecUpdate } from '~/app/shared/models/service.interface'; +import { TaskMessageService } from '~/app/shared/services/task-message.service'; + +@Component({ + selector: 'cd-nvmeof-gateway-node-add-modal', + templateUrl: './nvmeof-gateway-node-add-modal.component.html', + styleUrls: ['./nvmeof-gateway-node-add-modal.component.scss'], + standalone: false +}) +export class NvmeofGatewayNodeAddModalComponent extends CdForm implements OnInit, OnDestroy { + hosts: Host[] = []; + columns: CdTableColumn[] = []; + selection = new CdTableSelection(); + public gatewayAdded = new EventEmitter(); + isLoadingHosts = false; + private tableContext: CdTableFetchDataContext = null; + private sub = new Subscription(); + private readonly ADD_GATEWAY_NODE_TASK = 'nvmeof/gateway/node/add'; + + @ViewChild('statusTemplate', { static: true }) + statusTemplate!: TemplateRef; + + @ViewChild('labelsTemplate', { static: true }) + labelsTemplate!: TemplateRef; + + @ViewChild('addrTemplate', { static: true }) + addrTemplate!: TemplateRef; + + HostStatus = HostStatus; + + constructor( + private nvmeofService: NvmeofService, + private cephServiceService: CephServiceService, + private notificationService: NotificationService, + private taskMessageService: TaskMessageService, + @Inject('groupName') public groupName: string, + @Inject('usedHostnames') public usedHostnames: string[], + @Inject('serviceSpec') public serviceSpec: CephServiceSpec + ) { + super(); + } + + ngOnInit(): void { + this.columns = [ + { + name: $localize`Hostname`, + prop: 'hostname', + flexGrow: 2 + }, + { + name: $localize`IP address`, + prop: 'addr', + flexGrow: 2, + cellTemplate: this.addrTemplate + }, + { + name: $localize`Status`, + prop: 'status', + flexGrow: 1, + cellTemplate: this.statusTemplate + }, + { + name: $localize`Labels (tags)`, + prop: 'labels', + flexGrow: 3, + cellTemplate: this.labelsTemplate + } + ]; + } + + ngOnDestroy(): void { + this.sub.unsubscribe(); + } + + getHosts(context: CdTableFetchDataContext) { + if (context !== null) { + this.tableContext = context; + } + if (this.tableContext == null) { + this.tableContext = new CdTableFetchDataContext(() => undefined); + } + if (this.isLoadingHosts) { + return; + } + this.isLoadingHosts = true; + + this.sub.add( + this.nvmeofService.getAvailableHosts(this.tableContext?.toParams()).subscribe({ + next: (hostList: Host[]) => { + this.hosts = hostList; + this.isLoadingHosts = false; + }, + error: () => { + this.isLoadingHosts = false; + context.error(); + } + }) + ); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + onSubmit() { + if (!this.serviceSpec) { + this.notificationService.show( + NotificationType.error, + $localize`Service specification is missing.` + ); + return; + } + + this.loadingStart(); + + const modifiedSpec = this.createServiceSpecPayload(); + + this.cephServiceService.update(modifiedSpec).subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + this.taskMessageService.messages[this.ADD_GATEWAY_NODE_TASK].success({ + group_name: this.groupName + }) + ); + this.gatewayAdded.emit(); + this.loadingReady(); + this.closeModal(); + }, + error: (e) => { + this.loadingReady(); + this.notificationService.show( + NotificationType.error, + this.taskMessageService.messages[this.ADD_GATEWAY_NODE_TASK].failure({ + group_name: this.groupName + }), + e + ); + } + }); + } + + private createServiceSpecPayload(): CephServiceSpecUpdate { + const selectedHosts = this.selection.selected.map((h: Host) => h.hostname); + const currentHosts = this.serviceSpec.placement?.hosts || []; + const newHosts = [...currentHosts, ...selectedHosts]; + + const { status, ...modifiedSpec } = this.serviceSpec; + + if ('events' in modifiedSpec) { + delete (modifiedSpec as any).events; + } + + if (modifiedSpec.placement) { + modifiedSpec.placement = { ...modifiedSpec.placement }; + } else { + modifiedSpec.placement = {}; + } + + modifiedSpec.placement.hosts = newHosts; + + return modifiedSpec; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html index bdbde005406..77fc7e619fc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html @@ -11,7 +11,12 @@ [maxLimit]="25" identifier="hostname" forceIdentifier="true" + [autoReload]="false" (updateSelection)="updateSelection($event)" + emptyStateTitle="No nodes available" + i18n-emptyStateTitle + emptyStateMessage="Add your first gateway node to start using NVMe over Fabrics. Nodes provide the resources required to expose NVMe/TCP block storage." + i18n-emptyStateMessage > + + {{ value }} + + {{ value || '-' }} - - @if (value === HostStatus.AVAILABLE) { + @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) { + } @else { + } {{ value | titlecase }} @@ -50,8 +63,10 @@ } - + @if (value && value.length > 0) { {{ label }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts index 576b2e19ad6..3603b2c15c1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts @@ -104,9 +104,11 @@ describe('NvmeofGatewayNodeComponent', () => { provide: ActivatedRoute, useValue: { parent: { - params: new BehaviorSubject({ group: 'group1' }) + params: new BehaviorSubject({ group: 'group1' }), + snapshot: { + params: { group: 'group1' } + } }, - data: of({ mode: 'selector' }), snapshot: { data: { mode: 'selector' } } @@ -149,7 +151,7 @@ describe('NvmeofGatewayNodeComponent', () => { it('should initialize with default values', () => { expect(component.hosts).toEqual([]); expect(component.isLoadingHosts).toBe(false); - expect(component.totalHostCount).toBe(5); + expect(component.count).toBe(0); expect(component.permission).toBeDefined(); }); @@ -184,119 +186,34 @@ describe('NvmeofGatewayNodeComponent', () => { }); it('should load hosts with orchestrator available and facts feature enabled', fakeAsync(() => { - const hostListSpy = spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes)); - const mockOrcStatus: any = { - available: true, - features: new Map([['get_facts', { available: true }]]) - }; - - spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue( - of([ - [ - { - service_id: 'nvmeof.group1', - placement: { hosts: ['gateway-node-1'] } - } - ] - ] as any) - ); - spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); - component.groupName = 'group1'; - fixture.detectChanges(); - + spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of([mockGatewayNodes[2]])); component.getHosts(new CdTableFetchDataContext(() => undefined)); tick(100); - expect(hostListSpy).toHaveBeenCalled(); - // Hosts NOT in usedHostnames are included (gateway-node-1 is used, so filtered out) - // gateway-node-2 and gateway-node-3 are returned (status is not filtered) - expect(component.hosts.length).toBe(2); - expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-2'); - expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-3'); + expect(nvmeofService.getAvailableHosts).toHaveBeenCalled(); + expect(component.hosts.length).toBe(1); + expect(component.hosts[0]['hostname']).toBe('gateway-node-3'); })); it('should set count to hosts length', fakeAsync(() => { - spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes)); - const mockOrcStatus: any = { - available: true, - features: new Map() - }; - - spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue( - of([ - [ - { - service_id: 'nvmeof.group1', - placement: { hosts: ['gateway-node-1', 'gateway-node-2'] } - } - ] - ] as any) - ); - spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); - component.groupName = 'group1'; - fixture.detectChanges(); - + spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of(mockGatewayNodes)); component.getHosts(new CdTableFetchDataContext(() => undefined)); tick(100); - // Count should equal the filtered hosts length - expect(component.totalHostCount).toBe(component.hosts.length); + expect(component.count).toBe(component.hosts.length); })); it('should set count to 0 when no hosts are returned', fakeAsync(() => { - spyOn(hostService, 'list').and.returnValue(of([])); - const mockOrcStatus: any = { - available: true, - features: new Map() - }; - - spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue( - of([ - [ - { - service_id: 'nvmeof.group1', - placement: { hosts: ['gateway-node-1'] } - } - ] - ] as any) - ); - spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); - component.groupName = 'group1'; - fixture.detectChanges(); - + spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of([])); component.getHosts(new CdTableFetchDataContext(() => undefined)); tick(100); - expect(component.totalHostCount).toBe(0); + expect(component.count).toBe(0); expect(component.hosts.length).toBe(0); })); it('should handle error when fetching hosts', fakeAsync(() => { - const errorMsg = 'Failed to fetch hosts'; - spyOn(hostService, 'list').and.returnValue(throwError(() => new Error(errorMsg))); - const mockOrcStatus: any = { - available: true, - features: new Map() - }; - - spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue( - of([ - [ - { - service_id: 'nvmeof.group1', - placement: { hosts: ['gateway-node-1', 'gateway-node-2'] } - } - ] - ] as any) - ); - spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); - component.groupName = 'group1'; - fixture.detectChanges(); - + spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(throwError(() => new Error('Error'))); const context = new CdTableFetchDataContext(() => undefined); spyOn(context, 'error'); @@ -309,12 +226,12 @@ describe('NvmeofGatewayNodeComponent', () => { it('should not re-fetch if already loading', fakeAsync(() => { component.isLoadingHosts = true; - const hostListSpy = spyOn(hostService, 'list'); + spyOn(nvmeofService, 'getAvailableHosts'); component.getHosts(new CdTableFetchDataContext(() => undefined)); tick(100); - expect(hostListSpy).not.toHaveBeenCalled(); + expect(nvmeofService.getAvailableHosts).not.toHaveBeenCalled(); })); it('should unsubscribe on component destroy', fakeAsync(() => { @@ -461,7 +378,7 @@ describe('NvmeofGatewayNodeComponent', () => { })); it('should fetch data using fetchHostsAndGroups in details mode', fakeAsync(() => { - (component as any).route.data = of({ mode: 'details' }); + (component as any).route.snapshot.data = { mode: 'details' }; component.ngOnInit(); component.groupName = 'group1'; @@ -487,17 +404,16 @@ describe('NvmeofGatewayNodeComponent', () => { expect(nvmeofService.fetchHostsAndGroups).toHaveBeenCalled(); expect(component.hosts.length).toBe(1); expect(component.hosts[0].hostname).toBe('gateway-node-1'); - expect(component.hosts[0].hostname).toBe('gateway-node-1'); })); it('should set selectionType to multiClick in selector mode', () => { - (component as any).route.data = of({ mode: 'selector' }); + (component as any).route.snapshot.data = { mode: 'selector' }; component.ngOnInit(); expect(component.selectionType).toBe('multiClick'); }); it('should set selectionType to single in details mode', () => { - (component as any).route.data = of({ mode: 'details' }); + (component as any).route.snapshot.data = { mode: 'details' }; component.ngOnInit(); expect(component.selectionType).toBe('single'); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts index ae777677f54..42f50767e75 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts @@ -9,16 +9,12 @@ import { ViewChild } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; -import { forkJoin, Subject, Subscription } from 'rxjs'; -import { finalize, mergeMap } from 'rxjs/operators'; +import { Observable, Subject, Subscription } from 'rxjs'; +import { finalize } from 'rxjs/operators'; -import { HostService } from '~/app/shared/api/host.service'; -import { OrchestratorService } from '~/app/shared/api/orchestrator.service'; import { TableComponent } from '~/app/shared/datatable/table/table.component'; import { HostStatus } from '~/app/shared/enum/host-status.enum'; import { Icons } from '~/app/shared/enum/icons.enum'; -import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; - import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; @@ -30,6 +26,8 @@ import { Host } from '~/app/shared/models/host.interface'; import { CephServiceSpec } from '~/app/shared/models/service.interface'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component'; @Component({ selector: 'cd-nvmeof-gateway-node', @@ -55,11 +53,13 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { @Output() selectionChange = new EventEmitter(); @Output() hostsLoaded = new EventEmitter(); + @Input() groupName: string | undefined; - @Input() mode: NvmeofGatewayNodeMode = NvmeofGatewayNodeMode.SELECTOR; + @Input() mode: 'selector' | 'details' = 'selector'; usedHostnames: Set = new Set(); serviceSpec: CephServiceSpec | undefined; + hasAvailableHosts = false; permission: Permission; columns: CdTableColumn[] = []; @@ -72,50 +72,33 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { icons = Icons; HostStatus = HostStatus; private tableContext: CdTableFetchDataContext | undefined; - totalHostCount = 5; + count = 0; orchStatus: OrchestratorStatus | undefined; private destroy$ = new Subject(); private sub: Subscription | undefined; constructor( private authStorageService: AuthStorageService, - private hostService: HostService, - private orchService: OrchestratorService, private nvmeofService: NvmeofService, - private route: ActivatedRoute + private route: ActivatedRoute, + private modalService: ModalCdsService ) { this.permission = this.authStorageService.getPermissions().nvmeof; } ngOnInit(): void { - this.route.data.subscribe((data) => { - if (data?.['mode']) { - this.mode = data['mode']; - } - }); + const routeData = this.route.snapshot.data; + if (routeData?.['mode']) { + this.mode = routeData['mode']; + } - this.selectionType = this.mode === NvmeofGatewayNodeMode.SELECTOR ? 'multiClick' : 'single'; + this.selectionType = this.mode === 'selector' ? 'multiClick' : 'single'; - if (this.mode === NvmeofGatewayNodeMode.DETAILS) { - this.route.parent?.params.subscribe((params: { group: string }) => { + if (this.mode === 'details') { + this.route.parent?.params.subscribe((params: any) => { this.groupName = params.group; }); - this.tableActions = [ - { - permission: 'create', - icon: Icons.add, - click: () => this.addGateway(), - name: $localize`Add`, - canBePrimary: (selection: CdTableSelection) => !selection.hasSelection - }, - { - permission: 'delete', - icon: Icons.destroy, - click: () => this.removeGateway(), - name: $localize`Remove`, - disable: (selection: CdTableSelection) => !selection.hasSelection - } - ]; + this.setTableActions(); } this.columns = [ @@ -146,8 +129,36 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { ]; } + private setTableActions() { + this.tableActions = [ + { + permission: 'create', + icon: Icons.add, + click: () => this.addGateway(), + name: $localize`Add`, + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, + disable: () => (!this.hasAvailableHosts ? $localize`No available nodes to add` : false) + }, + { + permission: 'delete', + icon: Icons.destroy, + click: () => this.removeGateway(), + name: $localize`Remove`, + disable: (selection: CdTableSelection) => !selection.hasSelection + } + ]; + } + addGateway(): void { - // TODO + const modalRef = this.modalService.show(NvmeofGatewayNodeAddModalComponent, { + groupName: this.groupName, + usedHostnames: Array.from(this.usedHostnames), + serviceSpec: this.serviceSpec + }); + + modalRef.gatewayAdded.subscribe(() => { + this.table.refreshBtn(); + }); } removeGateway(): void { @@ -183,22 +194,10 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { this.sub.unsubscribe(); } - const fetchData$ = - this.mode === NvmeofGatewayNodeMode.DETAILS + const fetchData$: Observable = + this.mode === 'details' ? this.nvmeofService.fetchHostsAndGroups() - : forkJoin({ - groups: this.nvmeofService.listGatewayGroups(), - hosts: this.orchService.status().pipe( - mergeMap((orchStatus: OrchestratorStatus) => { - this.orchStatus = orchStatus; - const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus); - return this.hostService.list( - this.tableContext?.toParams(), - factsAvailable.toString() - ); - }) - ) - }); + : this.nvmeofService.getAvailableHosts(this.tableContext?.toParams()); this.sub = fetchData$ .pipe( @@ -208,47 +207,50 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { ) .subscribe({ next: (result: any) => { - this.mode === NvmeofGatewayNodeMode.DETAILS - ? this.processHostsForDetailsMode(result.groups, result.hosts) - : this.processHostsForSelectorMode(result.groups, result.hosts); + if (this.mode === 'details') { + this.processDetailsData(result.groups, result.hosts); + } else { + this.hosts = result; + this.count = this.hosts.length; + this.hostsLoaded.emit(this.count); + } }, error: () => context?.error() }); } - /** - * Selector Mode: Used in 'Add/Create' forms. - * Filters the entire cluster inventory to show only **available** candidates - * (excluding nodes that are already part of a gateway group). - */ - private processHostsForSelectorMode(groups: CephServiceSpec[][] = [[]], hostList: Host[] = []) { - const usedHosts = new Set(); - (groups?.[0] ?? []).forEach((group: CephServiceSpec) => { - group.placement?.hosts?.forEach((hostname: string) => usedHosts.add(hostname)); + private processDetailsData(groups: any[][], hostList: Host[]) { + const groupList = groups?.[0] ?? []; + + const allUsedHostnames = new Set(); + groupList.forEach((group: CephServiceSpec) => { + const hosts = group.placement?.hosts || (group.spec as any)?.placement?.hosts || []; + hosts.forEach((hostname: string) => allUsedHostnames.add(hostname)); }); - this.usedHostnames = usedHosts; - this.hosts = (hostList || []).filter((host: Host) => !this.usedHostnames.has(host.hostname)); + this.usedHostnames = allUsedHostnames; - this.updateCount(); - } - - /** - * Details Mode: Used in 'Details' views. - * Filters specifically for the nodes that are **configured members** - * of the current gateway group, regardless of their status. - */ - private processHostsForDetailsMode(groups: any[][], hostList: Host[]) { - const groupList = groups?.[0] ?? []; - const currentGroup: CephServiceSpec | undefined = groupList.find( - (group: CephServiceSpec) => group.spec?.group === this.groupName + // Check if there are any available hosts globally (not used by any group) + this.hasAvailableHosts = (hostList || []).some( + (host: Host) => !this.usedHostnames.has(host.hostname) ); + this.setTableActions(); + + const currentGroup = groupList.find((group: CephServiceSpec) => { + return ( + group.spec?.group === this.groupName || + group.service_id === `nvmeof.${this.groupName}` || + group.service_id.endsWith(`.${this.groupName}`) + ); + }); - if (!currentGroup) { + this.serviceSpec = currentGroup as CephServiceSpec; + + if (!this.serviceSpec) { this.hosts = []; } else { const placementHosts = - currentGroup.placement?.hosts || (currentGroup.spec as any)?.placement?.hosts || []; + this.serviceSpec.placement?.hosts || (this.serviceSpec.spec as any)?.placement?.hosts || []; const currentGroupHosts = new Set(placementHosts); this.hosts = (hostList || []).filter((host: Host) => { @@ -256,12 +258,7 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { }); } - this.serviceSpec = currentGroup; - this.updateCount(); - } - - private updateCount(): void { - this.totalHostCount = this.hosts.length; - this.hostsLoaded.emit(this.totalHostCount); + this.count = this.hosts.length; + this.hostsLoaded.emit(this.count); } } 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 46f38d08038..36c6b752a8b 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 @@ -1,18 +1,34 @@ import { TestBed } from '@angular/core/testing'; import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; import { configureTestBed } from '~/testing/unit-test-helper'; -import { NvmeofService } from '../../shared/api/nvmeof.service'; -import { throwError } from 'rxjs'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { HostService } from './host.service'; +import { OrchestratorService } from './orchestrator.service'; +import { of, throwError } from 'rxjs'; describe('NvmeofService', () => { let service: NvmeofService; let httpTesting: HttpTestingController; const mockGroupName = 'default'; const mockNQN = 'nqn.2001-07.com.ceph:1721041732363'; + const mockHostService = { + checkHostsFactsAvailable: jest.fn(), + list: jest.fn(), + getAllHosts: jest.fn() + }; + + const mockOrchService = { + status: jest.fn() + }; const UI_API_PATH = 'ui-api/nvmeof'; const API_PATH = 'api/nvmeof'; + configureTestBed({ - providers: [NvmeofService], + providers: [ + NvmeofService, + { provide: HostService, useValue: mockHostService }, + { provide: OrchestratorService, useValue: mockOrchService } + ], imports: [HttpClientTestingModule] }); @@ -56,7 +72,7 @@ describe('NvmeofService', () => { ] ]; - service.exists('default').subscribe((exists) => { + service.exists('default').subscribe((exists: boolean) => { expect(exists).toBe(true); }); @@ -75,7 +91,7 @@ describe('NvmeofService', () => { ] ]; - service.exists('non-existent-group').subscribe((exists) => { + service.exists('non-existent-group').subscribe((exists: boolean) => { expect(exists).toBe(false); }); @@ -85,7 +101,7 @@ describe('NvmeofService', () => { }); it('should check if gateway group exists - returns false on API error', () => { - service.exists('test-group').subscribe((exists) => { + service.exists('test-group').subscribe((exists: boolean) => { expect(exists).toBe(false); }); @@ -93,6 +109,41 @@ describe('NvmeofService', () => { expect(req.request.method).toBe('GET'); req.error(new ErrorEvent('Network error')); }); + + it('should get available hosts', () => { + const mockGroups = [[{ placement: { hosts: ['used-host'] } }]]; + const mockHosts = [ + { hostname: 'used-host', status: 'Available' }, + { hostname: 'free-host', status: 'Available' } + ]; + + mockOrchService.status.mockReturnValue(of({ available: true })); + mockHostService.checkHostsFactsAvailable.mockReturnValue(true); + mockHostService.list.mockReturnValue(of(mockHosts)); + + service.getAvailableHosts().subscribe((hosts: any[]) => { + expect(hosts.length).toBe(1); + expect(hosts[0].hostname).toBe('free-host'); + }); + + const req = httpTesting.expectOne(`${API_PATH}/gateway/group`); + req.flush(mockGroups); + }); + + it('should fetch hosts and groups', () => { + const mockGroups = [[{ spec: { group: 'group1' } }]]; + const mockHosts = [{ hostname: 'host1', status: '' }]; + + mockHostService.getAllHosts.mockReturnValue(of(mockHosts)); + + service.fetchHostsAndGroups().subscribe((result: any) => { + expect(result.groups).toEqual(mockGroups); + expect(result.hosts[0].status).toBe('Available'); + }); + + const req = httpTesting.expectOne(`${API_PATH}/gateway/group`); + req.flush(mockGroups); + }); }); describe('test subsystems APIs', () => { @@ -132,7 +183,7 @@ describe('NvmeofService', () => { }); it('should call isSubsystemPresent', () => { spyOn(service, 'getSubsystem').and.returnValue(throwError('test')); - service.isSubsystemPresent(mockNQN, mockGroupName).subscribe((res) => { + service.isSubsystemPresent(mockNQN, mockGroupName).subscribe((res: boolean) => { expect(res).toBe(false); }); }); 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 5aecd4cb76f..8937eb5026b 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 @@ -3,9 +3,13 @@ import { HttpClient } from '@angular/common/http'; import _ from 'lodash'; import { Observable, forkJoin, of as observableOf } from 'rxjs'; -import { catchError, map, mapTo } from 'rxjs/operators'; +import { catchError, map, mapTo, mergeMap } from 'rxjs/operators'; import { CephServiceSpec } from '../models/service.interface'; import { HostService } from './host.service'; +import { OrchestratorService } from './orchestrator.service'; +import { HostStatus } from '../enum/host-status.enum'; +import { Host } from '../models/host.interface'; +import { OrchestratorStatus } from '../models/orchestrator.interface'; export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512; @@ -49,12 +53,53 @@ const UI_API_PATH = 'ui-api/nvmeof'; providedIn: 'root' }) export class NvmeofService { - constructor(private http: HttpClient, private hostService: HostService) {} + constructor( + private http: HttpClient, + private hostService: HostService, + private orchService: OrchestratorService + ) {} - fetchHostsAndGroups() { + getAvailableHosts(params: any = {}): Observable { return forkJoin({ groups: this.listGatewayGroups(), - hosts: this.hostService.getAllHosts() + hosts: this.orchService.status().pipe( + mergeMap((orchStatus: OrchestratorStatus) => { + const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus); + return this.hostService.list(params, factsAvailable.toString()) as Observable; + }), + map((hosts: Host[]) => { + return (hosts || []).map((host: Host) => ({ + ...host, + status: host.status || HostStatus.AVAILABLE + })); + }) + ) + }).pipe( + map(({ groups, hosts }) => { + const usedHosts = new Set(); + (groups?.[0] ?? []).forEach((group: CephServiceSpec) => { + group.placement?.hosts?.forEach((hostname: string) => usedHosts.add(hostname)); + }); + return (hosts || []).filter((host: Host) => { + const isAvailable = + host.status === HostStatus.AVAILABLE || host.status === HostStatus.RUNNING; + return !usedHosts.has(host.hostname) && isAvailable; + }); + }) + ); + } + + fetchHostsAndGroups(): Observable<{ groups: CephServiceSpec[][]; hosts: Host[] }> { + return forkJoin({ + groups: this.listGatewayGroups(), + hosts: this.hostService.getAllHosts().pipe( + map((hosts: Host[]) => { + return (hosts || []).map((host: Host) => ({ + ...host, + status: host.status || HostStatus.AVAILABLE + })); + }) + ) }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts index 303590a814a..7fb34afd28f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/service.interface.ts @@ -41,6 +41,9 @@ export interface CephServiceSpec { placement: CephServicePlacement; } +// Type for service spec update payload (excludes read-only status field) +export type CephServiceSpecUpdate = Omit; + export interface CephServiceAdditionalSpec { backend_service: string; api_user: 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 8a7bdc6d04a..48cc978d279 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 @@ -378,6 +378,9 @@ export class TaskMessageService { 'nvmeof/gateway/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.nvmeofGateway(metadata) ), + 'nvmeof/gateway/node/add': this.newTaskMessage(this.commonOperations.add, (metadata) => + this.nvmeofGatewayNode(metadata) + ), 'nvmeof/subsystem/create': this.newTaskMessage(this.commonOperations.create, (metadata) => this.nvmeofSubsystem(metadata) ), @@ -589,6 +592,10 @@ export class TaskMessageService { nvmeofGateway(metadata: any) { return $localize`Gateway group '${metadata.group}'`; } + + nvmeofGatewayNode(metadata: any) { + return $localize`hosts to gateway group '${metadata.group_name}'`; + } nvmeofListener(metadata: any) { return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`; } -- 2.47.3