From 754e45c034ef172bdc3517814f6b6e1186414265 Mon Sep 17 00:00:00 2001 From: Sagar Gopale Date: Tue, 13 Jan 2026 12:59:32 +0530 Subject: [PATCH] mgr/dashboard: NVme-gateway-resource Fixes: https://tracker.ceph.com/issues/74334 Signed-off-by: Sagar Gopale --- .../src/app/ceph/block/block.module.ts | 41 ++++- ...e-gateway-view-breadcrumb.resolver.spec.ts | 48 +++++ .../nvme-gateway-view-breadcrumb.resolver.ts | 14 ++ .../nvme-gateway-view.component.html | 30 ++++ .../nvme-gateway-view.component.scss | 13 ++ .../nvme-gateway-view.component.spec.ts | 31 ++++ .../nvme-gateway-view.component.ts | 28 +++ .../nvmeof-gateway-group.component.html | 9 + .../nvmeof-gateway-group.component.ts | 51 +++++- .../nvmeof-gateway-node.component.html | 41 +++-- .../nvmeof-gateway-node.component.spec.ts | 164 ++++++++---------- .../nvmeof-gateway-node.component.ts | 45 +++-- .../nvmeof-gateway-subsystem.component.html | 30 ++++ .../nvmeof-gateway-subsystem.component.scss | 0 ...nvmeof-gateway-subsystem.component.spec.ts | 91 ++++++++++ .../nvmeof-gateway-subsystem.component.ts | 127 ++++++++++++++ .../src/app/shared/api/host.service.ts | 10 +- .../components/icon/icon.component.scss | 3 + .../src/app/shared/constants/app.constants.ts | 8 +- .../src/app/shared/enum/icons.enum.ts | 6 +- .../src/app/shared/enum/nvmeof.enum.ts | 5 + .../frontend/src/app/shared/models/nvmeof.ts | 9 + .../shared/services/url-builder.service.ts | 4 + .../src/styles/ceph-custom/_spacings.scss | 3 + 24 files changed, 673 insertions(+), 138 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.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 9725b2ab6bd..ab2a019a328 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; +import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; @@ -63,7 +63,9 @@ import { SelectModule, UIShellModule, TreeviewModule, + SideNavModule, TabsModule, + ThemeModule, TagModule } from 'carbon-components-angular'; @@ -75,9 +77,14 @@ import SubtractFilled from '@carbon/icons/es/subtract--filled/32'; import Reset from '@carbon/icons/es/reset/32'; import SubtractAlt from '@carbon/icons/es/subtract--alt/20'; import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32'; +import Search from '@carbon/icons/es/search/32'; import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component'; import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component'; + import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component'; +import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component'; +import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component'; +import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver'; @NgModule({ imports: [ @@ -105,6 +112,10 @@ import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway DatePickerModule, ComboBoxModule, TabsModule, + TabsModule, + SideNavModule, + ThemeModule, + TagModule, GridModule ], @@ -145,8 +156,12 @@ import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway NvmeofInitiatorsListComponent, NvmeofInitiatorsFormComponent, NvmeofGatewayNodeComponent, - NvmeofGroupFormComponent + NvmeofGroupFormComponent, + NvmeGatewayViewComponent, + NvmeofGatewaySubsystemComponent + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] }) export class BlockModule { @@ -158,7 +173,8 @@ export class BlockModule { SubtractFilled, Reset, ProgressBarRound, - SubtractAlt + SubtractAlt, + Search ]); } } @@ -300,11 +316,30 @@ const routes: Routes = [ children: [ { path: '', redirectTo: 'gateways', pathMatch: 'full' }, { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } }, + { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } }, { path: `gateways/${URLVerbs.CREATE}`, component: NvmeofGroupFormComponent, data: { breadcrumbs: `${ActionLabels.CREATE}${URLVerbs.GATEWAY_GROUP}` } }, + + { + path: `gateways/${URLVerbs.VIEW}/:group`, + component: NvmeGatewayViewComponent, + data: { breadcrumbs: `${ActionLabels.VIEW}${URLVerbs.GATEWAY_GROUP}` }, + children: [ + { + path: '', + component: NvmeofGatewayNodeComponent, + data: { breadcrumbs: NvmeGatewayViewBreadcrumbResolver } + }, + { + path: 'subsystems', + component: NvmeofSubsystemsDetailsComponent, + data: { breadcrumbs: 'Subsystems' } + } + ] + }, { path: 'subsystems', component: NvmeofSubsystemsComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.spec.ts new file mode 100644 index 00000000000..cd33e36a1fc --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.spec.ts @@ -0,0 +1,48 @@ +import { TestBed } from '@angular/core/testing'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view-breadcrumb.resolver'; + +describe('NvmeGatewayViewBreadcrumbResolver', () => { + let resolver: NvmeGatewayViewBreadcrumbResolver; + let route: ActivatedRouteSnapshot; + + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [NvmeGatewayViewBreadcrumbResolver] + }); + resolver = TestBed.inject(NvmeGatewayViewBreadcrumbResolver); + route = new ActivatedRouteSnapshot(); + }); + + it('should be created', () => { + expect(resolver).toBeTruthy(); + }); + + it('should resolve breadcrumb with group name from parent params', () => { + route.params = {}; + Object.defineProperty(route, 'parent', { + value: { params: { group: 'test-group' } }, + writable: true + }); + + spyOn(resolver, 'getFullPath').and.returnValue('full/path/test-group'); + + const result = resolver.resolve(route); + + expect(result).toEqual([{ text: 'test-group', path: 'full/path/test-group' }]); + }); + + it('should resolve breadcrumb with group name from current params', () => { + route.params = { group: 'test-group' }; + Object.defineProperty(route, 'parent', { + value: { params: {} }, + writable: true + }); + spyOn(resolver, 'getFullPath').and.returnValue('full/path/test-group'); + + const result = resolver.resolve(route); + + expect(result).toEqual([{ text: 'test-group', path: 'full/path/test-group' }]); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.ts new file mode 100644 index 00000000000..4bce58cace0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { ActivatedRouteSnapshot } from '@angular/router'; + +import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs'; + +@Injectable({ + providedIn: 'root' +}) +export class NvmeGatewayViewBreadcrumbResolver extends BreadcrumbsResolver { + resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] { + const group = route.parent?.params?.group || route.params?.group; + return [{ text: group, path: this.getFullPath(route) }]; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html new file mode 100644 index 00000000000..cc9f58bfcde --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html @@ -0,0 +1,30 @@ +
+

{{ groupName }}

+
+
+
+ + + Gateway nodes + + + Subsystems + + +
+ +
+ @if (selectedTab === 'gateways') { + + } + @if (selectedTab === 'subsystems') { + + + } +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss new file mode 100644 index 00000000000..f237cb0a2ab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss @@ -0,0 +1,13 @@ +@use '@carbon/colors'; +@use '@carbon/layout'; + +.nvme-shell { + min-height: calc(100vh - #{layout.rem(157px)}); + background-color: colors.$gray-10; + transform: translate(0); + position: relative; +} + +.nvme-main { + margin-left: layout.rem(272px); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.spec.ts new file mode 100644 index 00000000000..5893c72414a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.spec.ts @@ -0,0 +1,31 @@ +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { SideNavModule, ThemeModule } from 'carbon-components-angular'; + +import { RouterTestingModule } from '@angular/router/testing'; +import { NvmeGatewayViewComponent } from './nvme-gateway-view.component'; + +describe('NvmeGatewayViewComponent', () => { + let component: NvmeGatewayViewComponent; + let fixture: ComponentFixture; + + beforeEach( + waitForAsync(() => { + TestBed.configureTestingModule({ + declarations: [NvmeGatewayViewComponent], + imports: [RouterTestingModule, SideNavModule, ThemeModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA] + }).compileComponents(); + }) + ); + + beforeEach(() => { + fixture = TestBed.createComponent(NvmeGatewayViewComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts new file mode 100644 index 00000000000..ecf7eb5dc17 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts @@ -0,0 +1,28 @@ +import { Component, OnInit, ViewEncapsulation } from '@angular/core'; +import { ActivatedRoute, ParamMap } from '@angular/router'; +import { Observable, of } from 'rxjs'; +import { NvmeofSubsystem } from '~/app/shared/models/nvmeof'; + +@Component({ + selector: 'cd-nvme-gateway-view', + templateUrl: './nvme-gateway-view.component.html', + styleUrls: ['./nvme-gateway-view.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false +}) +export class NvmeGatewayViewComponent implements OnInit { + groupName: string; + subsystems$: Observable = of([]); + selectedTab: string | null = 'gateways'; + constructor(private route: ActivatedRoute) {} + + ngOnInit() { + this.route.paramMap.subscribe((pm: ParamMap) => { + this.groupName = pm.get('group') ?? ''; + }); + } + + selectTab(tab: string): void { + this.selectedTab = tab; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html index 8c629d76836..a92d454c604 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html @@ -25,6 +25,15 @@ {{ created | date:'EEE d MMM, yyyy' }} + + + {{ value }} + + +
; - @ViewChild('gatewayStatusTpl', { static: true }) - gatewayStatusTpl: TemplateRef; + @ViewChild('customTableItemTemplate', { static: true }) + customTableItemTemplate: TemplateRef; @ViewChild('deleteTpl', { static: true }) deleteTpl: TemplateRef; + @ViewChild('gatewayStatusTpl', { static: true }) + gatewayStatusTpl: TemplateRef; + + + permission: Permission; tableActions: CdTableAction[]; nodesAvailable = false; @@ -72,7 +79,8 @@ export class NvmeofGatewayGroupComponent implements OnInit { private cephServiceService: CephServiceService, public taskWrapper: TaskWrapperService, private notificationService: NotificationService, - private urlBuilder: URLBuilderService + private urlBuilder: URLBuilderService, + private router: Router ) {} ngOnInit(): void { @@ -81,7 +89,8 @@ export class NvmeofGatewayGroupComponent implements OnInit { this.columns = [ { name: $localize`Name`, - prop: 'name' + prop: 'name', + cellTemplate: this.customTableItemTemplate }, { name: $localize`Gateways`, @@ -107,6 +116,14 @@ export class NvmeofGatewayGroupComponent implements OnInit { canBePrimary: (selection: CdTableSelection) => !selection.hasSelection }; + const viewAction: CdTableAction = { + permission: 'read', + icon: Icons.eye, + click: () => this.getViewDetails(), + name: $localize`View details`, + canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection + }; + const deleteAction: CdTableAction = { permission: 'delete', icon: Icons.destroy, @@ -114,7 +131,10 @@ export class NvmeofGatewayGroupComponent implements OnInit { name: this.actionLabels.DELETE, canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection }; - this.tableActions = [createAction, deleteAction]; + + this.tableActions = [createAction, viewAction, deleteAction]; + + this.gatewayGroup$ = this.subject.pipe( switchMap(() => this.nvmeofService.listGatewayGroups().pipe( @@ -206,7 +226,8 @@ export class NvmeofGatewayGroupComponent implements OnInit { call: this.cephServiceService.delete(serviceName) }) .pipe( - tap(() => { + switchMap(() => timer(25000)), + map(() => { this.table.refreshBtn(); }), catchError((error) => { @@ -221,7 +242,6 @@ export class NvmeofGatewayGroupComponent implements OnInit { } }); } - private checkNodesAvailability(): void { forkJoin([this.nvmeofService.listGatewayGroups(), this.hostService.getAllHosts()]).subscribe( ([groups, hosts]: [GatewayGroup[][], any[]]) => { @@ -244,4 +264,17 @@ export class NvmeofGatewayGroupComponent implements OnInit { } ); } + + getViewDetails() { + const selectedGroup = this.selection.first(); + if (!selectedGroup) { + return; + } + const groupName = selectedGroup.spec?.group ?? selectedGroup.name ?? null; + if (!groupName) { + return; + } + const url = `/block/nvmeof/gateways/view/${encodeURIComponent(groupName)}`; + this.router.navigateByUrl(url); + } } 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 7dc01744901..57c1d697eef 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 @@ -1,21 +1,26 @@ - - - +
+ + + + +
{ component.selection = new CdTableSelection(); component.selection.selected = [mockGatewayNodes[0], mockGatewayNodes[1]]; - // ensure hosts list contains the selected hosts for lookup - component.hosts = [mockGatewayNodes[0], mockGatewayNodes[1]]; - - const selectedHosts = component - .getSelectedHostnames() - .map((hostname) => component.hosts.find((host) => host.hostname === hostname)); + const selectedHosts = component.getSelectedHosts(); expect(selectedHosts.length).toBe(2); expect(selectedHosts[0]).toEqual(mockGatewayNodes[0]); @@ -173,7 +169,8 @@ describe('NvmeofGatewayNodeComponent', () => { expect(selectedHostnames).toEqual(['gateway-node-1', 'gateway-node-2']); }); - it('should load hosts with orchestrator available and facts feature enabled', (done) => { + 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, @@ -187,18 +184,17 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect(hostListSpy).toHaveBeenCalled(); - // Only hosts with status 'available', '' or 'running' are included (excluding 'maintenance') - expect(component.hosts.length).toBe(2); - expect(component.isLoadingHosts).toBe(false); - expect(component.hosts[0]['hostname']).toBe('gateway-node-1'); - expect(component.hosts[0]['status']).toBe(HostStatus.AVAILABLE); - done(); - }, 100); - }); + tick(100); + expect(hostListSpy).toHaveBeenCalled(); + // Only hosts with status 'available', '' or 'running' are included (excluding 'maintenance') + expect(component.hosts.length).toBe(2); + expect(component.isLoadingHosts).toBe(false); + expect(component.hosts[0]['hostname']).toBe('gateway-node-1'); + expect(component.hosts[0]['status']).toBe('available'); + })); + + it('should normalize empty status to "available"', fakeAsync(() => { - it('should normalize empty status to "available"', (done) => { spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes)); const mockOrcStatus: any = { available: true, @@ -212,15 +208,14 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - // Host at index 1 in filtered list (gateway-node-3 has empty status which becomes 'available') - const nodeWithEmptyStatus = component.hosts.find((h) => h.hostname === 'gateway-node-3'); - expect(nodeWithEmptyStatus?.['status']).toBe(HostStatus.AVAILABLE); - done(); - }, 100); - }); + tick(100); + // Host at index 1 in filtered list (gateway-node-3 has empty status which becomes 'available') + const nodeWithEmptyStatus = component.hosts.find((h) => h.hostname === 'gateway-node-3'); + expect(nodeWithEmptyStatus?.['status']).toBe('available'); + })); + + it('should set count to hosts length', fakeAsync(() => { - it('should set count to hosts length', (done) => { spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes)); const mockOrcStatus: any = { available: true, @@ -234,14 +229,13 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - // Count should equal the filtered hosts length - expect(component.count).toBe(component.hosts.length); - done(); - }, 100); - }); + tick(100); + // Count should equal the filtered hosts length + expect(component.count).toBe(component.hosts.length); + })); + + it('should set count to 0 when no hosts are returned', fakeAsync(() => { - it('should set count to 0 when no hosts are returned', (done) => { spyOn(hostService, 'list').and.returnValue(of([])); const mockOrcStatus: any = { available: true, @@ -255,14 +249,13 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect(component.count).toBe(0); - expect(component.hosts.length).toBe(0); - done(); - }, 100); - }); + tick(100); + expect(component.count).toBe(0); + expect(component.hosts.length).toBe(0); + })); + + it('should handle error when fetching hosts', fakeAsync(() => { - it('should handle error when fetching hosts', (done) => { const errorMsg = 'Failed to fetch hosts'; spyOn(hostService, 'list').and.returnValue(throwError(() => new Error(errorMsg))); const mockOrcStatus: any = { @@ -280,12 +273,11 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(context); - setTimeout(() => { - expect(component.isLoadingHosts).toBe(false); - expect(context.error).toHaveBeenCalled(); - done(); - }, 100); - }); + tick(100); + expect(component.isLoadingHosts).toBe(false); + expect(context.error).toHaveBeenCalled(); + })); + it('should check hosts facts available when orchestrator features present', () => { component.orchStatus = { @@ -295,12 +287,14 @@ describe('NvmeofGatewayNodeComponent', () => { spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); + const result = component.checkHostsFactsAvailable(); expect(result).toBe(true); }); - it('should return false when get_facts feature is not available', () => { + it('should return true even when get_facts feature is not available', () => { + component.orchStatus = { available: true, features: new Map([['other_feature', { available: true }]]) @@ -308,10 +302,11 @@ describe('NvmeofGatewayNodeComponent', () => { const result = component.checkHostsFactsAvailable(); - expect(result).toBe(false); + expect(result).toBe(true); }); - it('should return false when orchestrator status features are empty', () => { + it('should return true even when orchestrator status features are empty', () => { + component.orchStatus = { available: true, features: new Map() @@ -319,7 +314,8 @@ describe('NvmeofGatewayNodeComponent', () => { const result = component.checkHostsFactsAvailable(); - expect(result).toBe(false); + expect(result).toBe(true); + }); it('should return false when orchestrator status is undefined', () => { @@ -330,30 +326,29 @@ describe('NvmeofGatewayNodeComponent', () => { expect(result).toBe(false); }); - it('should not re-fetch if already loading', (done) => { + it('should not re-fetch if already loading', fakeAsync(() => { + component.isLoadingHosts = true; const hostListSpy = spyOn(hostService, 'list'); component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect(hostListSpy).not.toHaveBeenCalled(); - done(); - }, 100); - }); + tick(100); + expect(hostListSpy).not.toHaveBeenCalled(); + })); + it('should unsubscribe on component destroy', () => { - const destroy$ = component['destroy$']; - spyOn(destroy$, 'next'); - spyOn(destroy$, 'complete'); + const sub = component['sub']; + spyOn(sub, 'unsubscribe'); component.ngOnDestroy(); - expect(destroy$.next).toHaveBeenCalled(); - expect(destroy$.complete).toHaveBeenCalled(); + expect(sub.unsubscribe).toHaveBeenCalled(); }); - it('should handle host list with various label types', (done) => { + it('should handle host list with various label types', fakeAsync(() => { + const hostsWithLabels = [ { ...mockGatewayNodes[0], @@ -378,14 +373,12 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect(component.hosts[0]['labels'].length).toBe(3); - expect(component.hosts[1]['labels'].length).toBe(0); - done(); - }, 100); - }); + tick(100); + expect(component.hosts[0]['labels'].length).toBe(3); + expect(component.hosts[1]['labels'].length).toBe(0); + })); - it('should handle hosts with multiple services', (done) => { + it('should handle hosts with multiple services', fakeAsync(() => { const hostsWithServices = [ { ...mockGatewayNodes[0], @@ -409,13 +402,11 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect(component.hosts[0]['services'].length).toBe(2); - done(); - }, 100); - }); + tick(100); + expect(component.hosts[0]['services'].length).toBe(2); + })); - it('should initialize table context on first getHosts call', (done) => { + it('should initialize table context on first getHosts call', fakeAsync(() => { spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes)); const mockOrcStatus: any = { available: true, @@ -431,13 +422,11 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect((component as any).tableContext).not.toBeNull(); - done(); - }, 100); - }); + tick(100); + expect((component as any).tableContext).not.toBeNull(); + })); - it('should reuse table context if already set', (done) => { + it('should reuse table context if already set', fakeAsync(() => { const context = new CdTableFetchDataContext(() => undefined); spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes)); const mockOrcStatus: any = { @@ -452,10 +441,9 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(context); - setTimeout(() => { - const storedContext = (component as any).tableContext; - expect(storedContext).toBe(context); - done(); - }, 100); - }); + tick(100); + const storedContext = (component as any).tableContext; + expect(storedContext).toBe(context); + })); + }); 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 69d61470c55..7fe06c92692 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 @@ -27,7 +27,6 @@ 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 _ from 'lodash'; @Component({ selector: 'cd-nvmeof-gateway-node', @@ -37,22 +36,19 @@ import _ from 'lodash'; }) export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { @ViewChild(TableComponent, { static: true }) - table: TableComponent; + table!: TableComponent; @ViewChild('hostNameTpl', { static: true }) - hostNameTpl: TemplateRef; + hostNameTpl!: TemplateRef; @ViewChild('statusTpl', { static: true }) - statusTpl: TemplateRef; + statusTpl!: TemplateRef; @ViewChild('addrTpl', { static: true }) - addrTpl: TemplateRef; + addrTpl!: TemplateRef; @ViewChild('labelsTpl', { static: true }) - labelsTpl: TemplateRef; - - @ViewChild('orchTmpl', { static: true }) - orchTmpl: TemplateRef; + labelsTpl!: TemplateRef; @Output() selectionChange = new EventEmitter(); @Output() hostsLoaded = new EventEmitter(); @@ -63,7 +59,8 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { columns: CdTableColumn[] = []; hosts: Host[] = []; isLoadingHosts = false; - tableActions: CdTableAction[]; + tableActions!: CdTableAction[]; + selection = new CdTableSelection(); icons = Icons; HostStatus = HostStatus; @@ -82,6 +79,23 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { } ngOnInit(): void { + 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.columns = [ { name: $localize`Hostname`, @@ -102,7 +116,8 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { cellTemplate: this.statusTpl }, { - name: $localize`Labels`, + name: $localize`Labels (tags)`, + prop: 'labels', flexGrow: 1, cellTemplate: this.labelsTpl @@ -110,6 +125,14 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { ]; } + addGateway(): void { + // TODO: Logic to open add gateway modal + } + + removeGateway(): void { + // TODO: Logic to remove gateway + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html new file mode 100644 index 00000000000..475fa19950e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html @@ -0,0 +1,30 @@ +
+ + +
+ + +
+ @if (row.auth === authType.NO_AUTH) { + + } @else { + + } + {{ row.auth }} +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts new file mode 100644 index 00000000000..19c09eb8080 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts @@ -0,0 +1,91 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; + +import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem.component'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NvmeofSubsystem } from '~/app/shared/models/nvmeof'; + +import { SharedModule } from '~/app/shared/shared.module'; + +describe('NvmeofGatewaySubsystemComponent', () => { + let component: NvmeofGatewaySubsystemComponent; + let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + + const mockSubsystems: NvmeofSubsystem[] = [ + { + nqn: 'nqn.2014-08.org.nvmexpress:uuid:1111', + enable_ha: true, + allow_any_host: true, + gw_group: 'group1', + serial_number: 'SN001', + model_number: 'MN001', + min_cntlid: 1, + max_cntlid: 65519, + max_namespaces: 256, + namespace_count: 0, + subtype: 'NVMe', + namespaces: [] + } as NvmeofSubsystem, + { + nqn: 'nqn.2014-08.org.nvmexpress:uuid:2222', + enable_ha: false, + allow_any_host: false, + gw_group: 'group1', + serial_number: 'SN002', + model_number: 'MN002', + min_cntlid: 1, + max_cntlid: 65519, + max_namespaces: 256, + namespace_count: 0, + subtype: 'NVMe', + namespaces: [] + } as NvmeofSubsystem + ]; + + const mockInitiators1 = [{ nqn: 'host1' }, { nqn: 'host2' }]; + const mockInitiators2 = [{ nqn: 'host3' }]; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofGatewaySubsystemComponent], + imports: [HttpClientTestingModule, SharedModule], + providers: [ + { + provide: NvmeofService, + useValue: { + listSubsystems: jest.fn(() => of(mockSubsystems)), + getInitiators: jest.fn((nqn) => { + if (nqn === 'nqn.2014-08.org.nvmexpress:uuid:1111') { + return of(mockInitiators1); + } + return of(mockInitiators2); + }) + } + } + ] + }).compileComponents(); + + nvmeofService = TestBed.inject(NvmeofService); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NvmeofGatewaySubsystemComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should verify getData fetches and processes data correctly', () => { + component.groupName = 'direct-test-group'; + component.getSubsystemsData(); + + expect(nvmeofService.listSubsystems).toHaveBeenCalledWith('direct-test-group'); + expect(component.subsystems.length).toBe(2); + expect(component.subsystems[0].nqn).toBe(mockSubsystems[0].nqn); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts new file mode 100644 index 00000000000..b67f8e9f091 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts @@ -0,0 +1,127 @@ +import { + Component, + Input, + OnInit, + OnChanges, + SimpleChanges, + TemplateRef, + ViewChild +} from '@angular/core'; +import { forkJoin, of } from 'rxjs'; +import { catchError, map, switchMap } from 'rxjs/operators'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { + NvmeofSubsystem, + NvmeofSubsystemData, + NvmeofSubsystemInitiator +} from '~/app/shared/models/nvmeof'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; + +import { Icons, ICON_TYPE } from '~/app/shared/enum/icons.enum'; +import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum'; + +@Component({ + selector: 'cd-nvmeof-gateway-subsystem', + templateUrl: './nvmeof-gateway-subsystem.component.html', + styleUrls: ['./nvmeof-gateway-subsystem.component.scss'], + standalone: false +}) +export class NvmeofGatewaySubsystemComponent implements OnInit, OnChanges { + @ViewChild('authTpl', { static: true }) + authTpl!: TemplateRef; + + @Input() groupName: string; + + columns: CdTableColumn[] = []; + + subsystems: NvmeofSubsystemData[] = []; + selection = new CdTableSelection(); + icons = Icons; + iconType = ICON_TYPE; + authType = NvmeofSubsystemAuthType; + + constructor(private nvmeofService: NvmeofService) {} + + ngOnInit(): void { + this.columns = [ + { + name: $localize`Subsystem NQN`, + prop: 'nqn', + flexGrow: 2 + }, + { + name: $localize`Authentication`, + prop: 'auth', + flexGrow: 1.5, + cellTemplate: this.authTpl + }, + { + name: $localize`Hosts (Initiators)`, + prop: 'hosts', + flexGrow: 1 + } + ]; + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes.groupName && this.groupName) { + this.getSubsystemsData(); + } + } + + getSubsystemsData() { + this.nvmeofService + .listSubsystems(this.groupName) + .pipe( + switchMap((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => { + const subs = Array.isArray(subsystems) ? subsystems : [subsystems]; + if (subs.length === 0) return of([]); + + return forkJoin( + subs.map((sub) => + this.nvmeofService.getInitiators(sub.nqn, this.groupName).pipe( + catchError(() => of([])), + map( + ( + initiators: NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] } + ) => { + let count = 0; + if (Array.isArray(initiators)) count = initiators.length; + else if (initiators?.hosts && Array.isArray(initiators.hosts)) { + count = initiators.hosts.length; + } + + let authStatus = NvmeofSubsystemAuthType.BIDIRECTIONAL; + if (sub.enable_ha === false) { + authStatus = NvmeofSubsystemAuthType.NO_AUTH; + } else if (sub.allow_any_host) { + authStatus = NvmeofSubsystemAuthType.UNIDIRECTIONAL; + } + + return { + ...sub, + auth: authStatus, + hosts: count + }; + } + ) + ) + ) + ); + }) + ) + .subscribe({ + next: (subsystems: NvmeofSubsystemData[]) => { + this.subsystems = subsystems; + }, + error: () => { + this.subsystems = []; + } + }); + } + + updateSelection(selection: CdTableSelection): void { + this.selection = selection; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts index 306dca8d547..b6392c79afd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts @@ -14,7 +14,7 @@ import { CdDevice } from '../models/devices'; import { SmartDataResponseV1 } from '../models/smart'; import { DeviceService } from '../services/device.service'; import { Host } from '../models/host.interface'; -import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface'; + @Injectable({ providedIn: 'root' @@ -170,10 +170,10 @@ export class HostService extends ApiClient { return this.http.get(`${this.baseUIURL}/list`); } - checkHostsFactsAvailable(orchStatus?: OrchestratorStatus): boolean { - const orchFeatures = orchStatus?.features; - if (!_.isEmpty(orchFeatures)) { - return !!orchFeatures.get_facts?.available; + checkHostsFactsAvailable(orchStatus: any) { + if (orchStatus?.available) { + return true; + } return false; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss index 2024593ec22..389fe60f454 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss @@ -42,3 +42,6 @@ Using `color` in css and seyting svg will fill="currentColor does not work. .deploy-icon { fill: theme.$layer-selected-disabled !important; } +.emptySearch-icon { + fill: theme.$layer-selected-disabled !important; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index 08cc137916b..bf66bed88ea 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -33,6 +33,7 @@ export enum URLVerbs { /* Non-standard verbs */ COPY = 'copy', CLONE = 'clone', + VIEW = 'view', /* Prometheus wording */ RECREATE = 'recreate', @@ -45,6 +46,7 @@ export enum URLVerbs { CONNECT = 'connect', RECONNECT = 'reconnect', GATEWAY_GROUP = 'Gateway group' + } export enum ActionLabels { @@ -87,7 +89,8 @@ export enum ActionLabels { /* Multi-cluster */ CONNECT = 'connect', - RECONNECT = 'reconnect' + RECONNECT = 'reconnect', + VIEW = 'View' } @Injectable({ @@ -162,7 +165,7 @@ export class ActionLabelsI18n { EXPAND_CLUSTER: string; SETUP_MULTISITE_REPLICATION: string; NFS_EXPORT: string; - + VIEW: string; constructor() { /* Create a new item */ this.CREATE = $localize`Create`; @@ -254,6 +257,7 @@ export class ActionLabelsI18n { this.EXPAND_CLUSTER = $localize`Expand Cluster`; this.NFS_EXPORT = $localize`Create NFS Export`; + this.VIEW = $localize`View`; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 5f45acf054e..fbc448735f0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -102,7 +102,8 @@ export enum Icons { spin = 'fa fa-spin', // To get any icon to rotate inverse = 'fa fa-inverse', // To get an alternative icon color notification = 'notification', - error = 'error--filled' + error = 'error--filled', + emptySearch = 'search' } export enum IconSize { @@ -122,5 +123,6 @@ export const ICON_TYPE = { infoCircle: 'info-circle', notification: 'notification', success: 'success', - warning: 'warning' + warning: 'warning', + emptySearch: 'emptySearch' } as const; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts new file mode 100644 index 00000000000..1e06864a871 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts @@ -0,0 +1,5 @@ +export enum NvmeofSubsystemAuthType { + NO_AUTH = 'No authentication', + UNIDIRECTIONAL = 'Unidirectional', + BIDIRECTIONAL = 'Bi-directional' +} 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 43a24a4b5ce..19fd40111ce 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 @@ -19,6 +19,15 @@ export interface NvmeofSubsystem { namespace_count: number; subtype: string; max_namespaces: number; + allow_any_host?: boolean; + enable_ha?: boolean; + gw_group?: string; + initiator_count?: number; +} + +export interface NvmeofSubsystemData extends NvmeofSubsystem { + auth?: string; + hosts?: number; } export interface NvmeofSubsystemInitiator { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts index b63489b8bce..9b750e7fe5c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts @@ -55,4 +55,8 @@ export class URLBuilderService { getReconnect(item: string, absolute = true): string { return this.getURL(URLVerbs.RECONNECT, absolute, item); } + + getView(absolute = true): string { + return this.getURL(URLVerbs.VIEW, absolute); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss index 12abe388835..f2038758e30 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -31,3 +31,6 @@ .cds-mt-5 { margin-top: layout.$spacing-05; } +.cds-pt-6 { + padding-top: layout.$spacing-06; +} -- 2.47.3