From: Sagar Gopale Date: Tue, 10 Feb 2026 06:21:32 +0000 (+0530) Subject: mgr/dashboard: NVme-gateway-resource X-Git-Tag: testing/wip-vshankar-testing-20260212.053105~2^2 X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=e7c163eefa44bbd8dd66b5d4a17bdef03d91074f;p=ceph-ci.git mgr/dashboard: NVme-gateway-resource Fixes: https://tracker.ceph.com/issues/74334 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 e97a60b3209..bc3a68c36be 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 @@ -69,11 +69,13 @@ import { SelectModule, UIShellModule, TreeviewModule, + SideNavModule, TabsModule, TagModule, LayoutModule, ContainedListModule, - LayerModule + LayerModule, + ThemeModule } from 'carbon-components-angular'; // Icons @@ -84,6 +86,11 @@ 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 { 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'; +import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; @NgModule({ imports: [ @@ -100,8 +107,8 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32'; TreeviewModule, UIShellModule, InputModule, - GridModule, ButtonModule, + GridModule, IconModule, CheckboxModule, RadioModule, @@ -115,7 +122,9 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32'; GridModule, LayerModule, LayoutModule, - ContainedListModule + ContainedListModule, + SideNavModule, + ThemeModule ], declarations: [ RbdListComponent, @@ -157,8 +166,11 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32'; NvmeofGroupFormComponent, NvmeofSubsystemsStepOneComponent, NvmeofSubsystemsStepTwoComponent, - NvmeofSubsystemsStepThreeComponent + NvmeofSubsystemsStepThreeComponent, + NvmeGatewayViewComponent, + NvmeofGatewaySubsystemComponent ], + exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] }) export class BlockModule { @@ -170,7 +182,8 @@ export class BlockModule { SubtractFilled, Reset, ProgressBarRound, - SubtractAlt + SubtractAlt, + Search ]); } } @@ -317,6 +330,25 @@ const routes: Routes = [ component: NvmeofGroupFormComponent, data: { breadcrumbs: `${ActionLabels.CREATE}${URLVerbs.GATEWAY_GROUP}` } }, + + { + path: `gateways/${URLVerbs.VIEW}/:group`, + component: NvmeGatewayViewComponent, + data: { breadcrumbs: NvmeGatewayViewBreadcrumbResolver }, // Use resolver here + children: [ + { path: '', redirectTo: 'nodes', pathMatch: 'full' }, + { + path: 'nodes', + component: NvmeofGatewayNodeComponent, + data: { breadcrumbs: $localize`Gateway nodes`, mode: NvmeofGatewayNodeMode.DETAILS } + }, + { + path: 'subsystems', + component: NvmeofGatewaySubsystemComponent, + data: { breadcrumbs: $localize`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..687ee4913a0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html @@ -0,0 +1,4 @@ + 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..9d8edd73999 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss @@ -0,0 +1,8 @@ +.breadcrumbs--padding { + padding-left: 0 !important; +} + +.cds--breadcrumb { + margin-top: 0; + padding: var(--cds-spacing-05); +} 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..f5f13e38442 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts @@ -0,0 +1,38 @@ +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'; +import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component'; + +@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([]); + public readonly basePath = '/block/nvmeof/gateways/view'; + sidebarItems: SidebarItem[] = []; + + constructor(private route: ActivatedRoute) {} + + ngOnInit() { + this.route.paramMap.subscribe((pm: ParamMap) => { + this.groupName = pm.get('group') ?? ''; + this.sidebarItems = [ + { + label: $localize`Gateway nodes`, + route: [this.basePath, this.groupName, 'nodes'], + routerLinkActiveOptions: { exact: true } + }, + { + label: $localize`Subsystems`, + route: [this.basePath, this.groupName, 'subsystems'] + } + ]; + }); + } +} 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..c3aaa0d4a4f 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; @@ -59,6 +63,7 @@ export class NvmeofGatewayGroupComponent implements OnInit { subsystemCount: number; gatewayCount: number; + viewUrl = `/${BASE_URL}/view`; icons = Icons; iconSize = IconSize; @@ -72,7 +77,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 +87,8 @@ export class NvmeofGatewayGroupComponent implements OnInit { this.columns = [ { name: $localize`Name`, - prop: 'name' + prop: 'name', + cellTemplate: this.customTableItemTemplate }, { name: $localize`Gateways`, @@ -107,6 +114,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 +129,9 @@ 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( @@ -207,7 +224,7 @@ export class NvmeofGatewayGroupComponent implements OnInit { call: this.cephServiceService.delete(serviceName) }) .pipe( - tap(() => { + map(() => { this.table.refreshBtn(); }), catchError((error) => { @@ -222,7 +239,6 @@ export class NvmeofGatewayGroupComponent implements OnInit { } }); } - private checkNodesAvailability(): void { forkJoin([this.nvmeofService.listGatewayGroups(), this.hostService.getAllHosts()]).subscribe( ([groups, hosts]: [GatewayGroup[][], any[]]) => { @@ -245,4 +261,16 @@ export class NvmeofGatewayGroupComponent implements OnInit { } ); } + + getViewDetails() { + const selectedGroup = this.selection.first(); + if (!selectedGroup) { + return; + } + const groupName = selectedGroup.name; + if (!groupName) { + return; + } + this.router.navigate([this.viewUrl, groupName]); + } } 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..bdbde005406 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 @@ - - - +
+ + + + +
+@if (value) {
} - {{ value | titlecase }}
- +} @else { + - +}
{ CoreModule, TagModule ], - providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }] + providers: [ + { provide: AuthStorageService, useValue: fakeAuthStorageService }, + { + provide: ActivatedRoute, + useValue: { + parent: { + params: new BehaviorSubject({ group: 'group1' }) + }, + data: of({ mode: 'selector' }), + snapshot: { + data: { mode: 'selector' } + } + } + } + ] }); beforeEach(() => { @@ -134,7 +149,7 @@ describe('NvmeofGatewayNodeComponent', () => { it('should initialize with default values', () => { expect(component.hosts).toEqual([]); expect(component.isLoadingHosts).toBe(false); - expect(component.count).toBe(5); + expect(component.totalHostCount).toBe(5); expect(component.permission).toBeDefined(); }); @@ -152,16 +167,11 @@ describe('NvmeofGatewayNodeComponent', () => { 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.getSelectedHostnames(); expect(selectedHosts.length).toBe(2); - expect(selectedHosts[0]).toEqual(mockGatewayNodes[0]); - expect(selectedHosts[1]).toEqual(mockGatewayNodes[1]); + expect(selectedHosts[0]).toEqual(mockGatewayNodes[0].hostname); + expect(selectedHosts[1]).toEqual(mockGatewayNodes[1].hostname); }); it('should get selected hostnames', () => { @@ -173,7 +183,7 @@ 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, @@ -181,46 +191,32 @@ describe('NvmeofGatewayNodeComponent', () => { }; spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); - spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); - fixture.detectChanges(); - - 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); - }); - - it('should normalize empty status to "available"', (done) => { - 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([[]])); + 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(); 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); + 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'); + })); - it('should set count to hosts length', (done) => { + it('should set count to hosts length', fakeAsync(() => { spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes)); const mockOrcStatus: any = { available: true, @@ -228,20 +224,28 @@ describe('NvmeofGatewayNodeComponent', () => { }; spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); + 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(); 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.totalHostCount).toBe(component.hosts.length); + })); - it('should set count to 0 when no hosts are returned', (done) => { + it('should set count to 0 when no hosts are returned', fakeAsync(() => { spyOn(hostService, 'list').and.returnValue(of([])); const mockOrcStatus: any = { available: true, @@ -249,20 +253,28 @@ describe('NvmeofGatewayNodeComponent', () => { }; spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); + 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(); component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect(component.count).toBe(0); - expect(component.hosts.length).toBe(0); - done(); - }, 100); - }); + tick(100); + expect(component.totalHostCount).toBe(0); + expect(component.hosts.length).toBe(0); + })); - it('should handle error when fetching hosts', (done) => { + 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 = { @@ -271,8 +283,18 @@ describe('NvmeofGatewayNodeComponent', () => { }; spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); + 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(); const context = new CdTableFetchDataContext(() => undefined); @@ -280,80 +302,37 @@ describe('NvmeofGatewayNodeComponent', () => { component.getHosts(context); - setTimeout(() => { - expect(component.isLoadingHosts).toBe(false); - expect(context.error).toHaveBeenCalled(); - done(); - }, 100); - }); - - it('should check hosts facts available when orchestrator features present', () => { - component.orchStatus = { - available: true, - features: new Map([['get_facts', { available: true }]]) - } as any; - - 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', () => { - component.orchStatus = { - available: true, - features: new Map([['other_feature', { available: true }]]) - } as any; - - const result = component.checkHostsFactsAvailable(); - - expect(result).toBe(false); - }); - - it('should return false when orchestrator status features are empty', () => { - component.orchStatus = { - available: true, - features: new Map() - } as any; - - const result = component.checkHostsFactsAvailable(); - - expect(result).toBe(false); - }); - - it('should return false when orchestrator status is undefined', () => { - component.orchStatus = undefined; - - const result = component.checkHostsFactsAvailable(); - - expect(result).toBe(false); - }); + tick(100); + expect(component.isLoadingHosts).toBe(false); + expect(context.error).toHaveBeenCalled(); + })); - 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'); + it('should unsubscribe on component destroy', fakeAsync(() => { + spyOn(hostService, 'list').and.returnValue(of([])); + spyOn(orchService, 'status').and.returnValue(of({} as any)); + spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); + component.getHosts(new CdTableFetchDataContext(() => undefined)); + tick(100); + + 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], @@ -372,20 +351,28 @@ describe('NvmeofGatewayNodeComponent', () => { }; spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); + spyOn(nvmeofService, 'listGatewayGroups').and.returnValue( + of([ + [ + { + service_id: 'nvmeof.group1', + placement: { hosts: ['gateway-node-2'] } + } + ] + ] as any) + ); spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); + component.groupName = 'group1'; fixture.detectChanges(); 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], @@ -403,19 +390,27 @@ describe('NvmeofGatewayNodeComponent', () => { }; spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); + spyOn(nvmeofService, 'listGatewayGroups').and.returnValue( + of([ + [ + { + service_id: 'nvmeof.group1', + placement: { hosts: ['gateway-node-2'] } + } + ] + ] as any) + ); spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true); + component.groupName = 'group1'; fixture.detectChanges(); 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, @@ -423,21 +418,29 @@ describe('NvmeofGatewayNodeComponent', () => { }; spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus)); - spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]])); + 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(); - expect((component as any).tableContext).toBeNull(); + expect((component as any).tableContext).toBeUndefined(); component.getHosts(new CdTableFetchDataContext(() => undefined)); - setTimeout(() => { - expect((component as any).tableContext).not.toBeNull(); - done(); - }, 100); - }); + tick(100); + expect((component as any).tableContext).toBeDefined(); + })); - 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 +455,50 @@ 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); + })); + + it('should fetch data using fetchHostsAndGroups in details mode', fakeAsync(() => { + (component as any).route.data = of({ mode: 'details' }); + component.ngOnInit(); + component.groupName = 'group1'; + + spyOn(nvmeofService, 'fetchHostsAndGroups').and.returnValue( + of({ + groups: [ + [ + { + service_id: 'nvmeof.group1', + spec: { group: 'group1' }, + placement: { hosts: ['gateway-node-1'] } + } + ] + ], + hosts: mockGatewayNodes + } as any) + ); + + fixture.detectChanges(); + component.getHosts(new CdTableFetchDataContext(() => undefined)); + tick(100); + + 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.ngOnInit(); + expect(component.selectionType).toBe('multiClick'); + }); + + it('should set selectionType to single in details mode', () => { + (component as any).route.data = of({ 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 69d61470c55..ae777677f54 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 @@ -1,20 +1,24 @@ import { Component, EventEmitter, + Input, OnDestroy, OnInit, Output, TemplateRef, ViewChild } from '@angular/core'; -import { forkJoin, Subject } from 'rxjs'; -import { map, mergeMap, takeUntil } from 'rxjs/operators'; +import { ActivatedRoute } from '@angular/router'; +import { forkJoin, Subject, Subscription } from 'rxjs'; +import { finalize, mergeMap } 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'; @@ -27,8 +31,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', templateUrl: './nvmeof-gateway-node.component.html', @@ -37,51 +39,85 @@ 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(); + @Input() groupName: string | undefined; + @Input() mode: NvmeofGatewayNodeMode = NvmeofGatewayNodeMode.SELECTOR; usedHostnames: Set = new Set(); + serviceSpec: CephServiceSpec | undefined; permission: Permission; columns: CdTableColumn[] = []; hosts: Host[] = []; isLoadingHosts = false; - tableActions: CdTableAction[]; + tableActions: CdTableAction[] = []; + selectionType: 'single' | 'multiClick' | 'none' = 'single'; + selection = new CdTableSelection(); icons = Icons; HostStatus = HostStatus; - private tableContext: CdTableFetchDataContext = null; - count = 5; - orchStatus: OrchestratorStatus; + private tableContext: CdTableFetchDataContext | undefined; + totalHostCount = 5; + 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 nvmeofService: NvmeofService, + private route: ActivatedRoute ) { this.permission = this.authStorageService.getPermissions().nvmeof; } ngOnInit(): void { + this.route.data.subscribe((data) => { + if (data?.['mode']) { + this.mode = data['mode']; + } + }); + + this.selectionType = this.mode === NvmeofGatewayNodeMode.SELECTOR ? 'multiClick' : 'single'; + + if (this.mode === NvmeofGatewayNodeMode.DETAILS) { + this.route.parent?.params.subscribe((params: { group: string }) => { + 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.columns = [ { name: $localize`Hostname`, @@ -102,7 +138,7 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { cellTemplate: this.statusTpl }, { - name: $localize`Labels`, + name: $localize`Labels (tags)`, prop: 'labels', flexGrow: 1, cellTemplate: this.labelsTpl @@ -110,9 +146,20 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { ]; } + addGateway(): void { + // TODO + } + + removeGateway(): void { + // TODO + } + ngOnDestroy(): void { this.destroy$.next(); this.destroy$.complete(); + if (this.sub) { + this.sub.unsubscribe(); + } } updateSelection(selection: CdTableSelection): void { @@ -125,73 +172,96 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy { } getHosts(context: CdTableFetchDataContext): void { - if (context !== null) { - this.tableContext = context; - } - if (this.tableContext == null) { - this.tableContext = new CdTableFetchDataContext(() => undefined); - } + this.tableContext = + context || this.tableContext || new CdTableFetchDataContext(() => undefined); if (this.isLoadingHosts) { return; } this.isLoadingHosts = true; - forkJoin([this.buildUsedHostsObservable(), this.buildHostListObservable()]) - .pipe(takeUntil(this.destroy$)) - .subscribe( - ([usedHostnames, hostList]: [Set, Host[]]) => - this.processHostResults(usedHostnames, hostList), - () => { + if (this.sub) { + this.sub.unsubscribe(); + } + + const fetchData$ = + this.mode === NvmeofGatewayNodeMode.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.sub = fetchData$ + .pipe( + finalize(() => { this.isLoadingHosts = false; - context.error(); - } - ); + }) + ) + .subscribe({ + next: (result: any) => { + this.mode === NvmeofGatewayNodeMode.DETAILS + ? this.processHostsForDetailsMode(result.groups, result.hosts) + : this.processHostsForSelectorMode(result.groups, result.hosts); + }, + error: () => context?.error() + }); } - private buildUsedHostsObservable() { - return this.nvmeofService.listGatewayGroups().pipe( - map((groups: CephServiceSpec[][]) => { - const usedHosts = new Set(); - const groupList = groups?.[0] ?? []; - groupList.forEach((group: CephServiceSpec) => { - const hosts = group.placement?.hosts || []; - hosts.forEach((hostname: string) => usedHosts.add(hostname)); - }); - return usedHosts; - }) - ); + /** + * 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)); + }); + this.usedHostnames = usedHosts; + + this.hosts = (hostList || []).filter((host: Host) => !this.usedHostnames.has(host.hostname)); + + this.updateCount(); } - private buildHostListObservable() { - return this.orchService.status().pipe( - mergeMap((orchStatus) => { - this.orchStatus = orchStatus; - const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus); - return this.hostService.list(this.tableContext?.toParams(), factsAvailable.toString()); - }) + /** + * 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 ); - } - private processHostResults(usedHostnames: Set, hostList: Host[]) { - this.usedHostnames = usedHostnames; - this.hosts = (hostList || []) - .map((host: Host) => ({ - ...host, - status: host.status || HostStatus.AVAILABLE - })) - .filter((host: Host) => { - const isNotUsed = !this.usedHostnames.has(host.hostname); - const status = host.status || HostStatus.AVAILABLE; - const isAvailable = status === HostStatus.AVAILABLE || status === HostStatus.RUNNING; - return isNotUsed && isAvailable; + if (!currentGroup) { + this.hosts = []; + } else { + const placementHosts = + currentGroup.placement?.hosts || (currentGroup.spec as any)?.placement?.hosts || []; + const currentGroupHosts = new Set(placementHosts); + + this.hosts = (hostList || []).filter((host: Host) => { + return currentGroupHosts.has(host.hostname); }); + } - this.isLoadingHosts = false; - this.count = this.hosts.length; - this.hostsLoaded.emit(this.count); + this.serviceSpec = currentGroup; + this.updateCount(); } - checkHostsFactsAvailable(): boolean { - return this.hostService.checkHostsFactsAvailable(this.orchStatus); + private updateCount(): void { + this.totalHostCount = this.hosts.length; + this.hostsLoaded.emit(this.totalHostCount); } } 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..84db49150d8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html @@ -0,0 +1,32 @@ +
+ + +
+ + +
+ @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..3553beea6d2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts @@ -0,0 +1,100 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ActivatedRoute } from '@angular/router'; +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); + }) + } + }, + { + provide: ActivatedRoute, + useValue: { + parent: { + params: of({ group: 'group1' }) + } + } + } + ] + }).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..aa46f503b5a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts @@ -0,0 +1,138 @@ +import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +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 { 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 { + @ViewChild('authTpl', { static: true }) + authTpl!: TemplateRef; + + groupName!: string; + + columns: CdTableColumn[] = []; + + subsystems: NvmeofSubsystemData[] = []; + selection = new CdTableSelection(); + + iconType = ICON_TYPE; + authType = NvmeofSubsystemAuthType; + + constructor(private nvmeofService: NvmeofService, private route: ActivatedRoute) {} + + 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 + } + ]; + + this.route.parent?.params.subscribe((params) => { + if (params['group']) { + this.groupName = params['group']; + 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.NO_AUTH; + if (sub.psk) { + authStatus = NvmeofSubsystemAuthType.BIDIRECTIONAL; + } else if ( + initiators && + 'hosts' in initiators && + Array.isArray(initiators.hosts) + ) { + const hasDhchapKey = initiators.hosts.some( + (host: NvmeofSubsystemInitiator) => !!host.dhchap_key + ); + if (hasDhchapKey) { + authStatus = NvmeofSubsystemAuthType.UNIDIRECTIONAL; + } + } else if (Array.isArray(initiators)) { + // Fallback for unexpected structure, though getInitiators usually returns {hosts: []} + const hasDhchapKey = (initiators as NvmeofSubsystemInitiator[]).some( + (host: NvmeofSubsystemInitiator) => !!host.dhchap_key + ); + if (hasDhchapKey) { + 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..b9233e0a461 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'; +import { OrchestratorStatus } from '../models/orchestrator.interface'; @Injectable({ providedIn: 'root' @@ -170,10 +170,9 @@ 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: OrchestratorStatus) { + if (orchStatus?.available) { + return true; } return 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 d74e7e0fb20..5aecd4cb76f 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 @@ -2,9 +2,10 @@ import { Injectable } from '@angular/core'; import { HttpClient } from '@angular/common/http'; import _ from 'lodash'; -import { Observable, of as observableOf } from 'rxjs'; +import { Observable, forkJoin, of as observableOf } from 'rxjs'; import { catchError, map, mapTo } from 'rxjs/operators'; import { CephServiceSpec } from '../models/service.interface'; +import { HostService } from './host.service'; export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512; @@ -48,7 +49,14 @@ const UI_API_PATH = 'ui-api/nvmeof'; providedIn: 'root' }) export class NvmeofService { - constructor(private http: HttpClient) {} + constructor(private http: HttpClient, private hostService: HostService) {} + + fetchHostsAndGroups() { + return forkJoin({ + groups: this.listGatewayGroups(), + hosts: this.hostService.getAllHosts() + }); + } // formats the gateway groups to be consumed for combobox item formatGwGroupsList( diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 69845644dbd..c7d2258e880 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -110,6 +110,7 @@ import { Close16 } from '@carbon/icons'; import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component'; import { ProductiveCardComponent } from './productive-card/productive-card.component'; import { PageHeaderComponent } from './page-header/page-header.component'; +import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component'; @NgModule({ imports: [ @@ -208,7 +209,8 @@ import { PageHeaderComponent } from './page-header/page-header.component'; TearsheetComponent, TearsheetStepComponent, ProductiveCardComponent, - PageHeaderComponent + PageHeaderComponent, + SidebarLayoutComponent ], providers: [provideCharts(withDefaultRegisterables())], exports: [ @@ -255,7 +257,8 @@ import { PageHeaderComponent } from './page-header/page-header.component'; TearsheetComponent, TearsheetStepComponent, ProductiveCardComponent, - PageHeaderComponent + PageHeaderComponent, + SidebarLayoutComponent ] }) export class ComponentsModule { 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 7f88eb3dd0b..28d9c1cfe18 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 @@ -46,3 +46,7 @@ Using `color` in css and seyting svg will fill="currentColor does not work. .notificationNew-icon circle { fill: theme.$support-error !important; } + +.emptySearch-icon { + fill: theme.$layer-selected-disabled !important; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html new file mode 100644 index 00000000000..00389576dab --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html @@ -0,0 +1,23 @@ +@if (title) { + +} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss new file mode 100644 index 00000000000..92b594efd04 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss @@ -0,0 +1,22 @@ +@use './src/styles/vendor/variables' as vv; +@use '@carbon/colors'; +@use '@carbon/layout'; + +.sidebar-layout-container { + min-height: calc(100vh - (vv.$navbar-height + layout.rem(55px))); + background-color: var(--cds-background); + padding-right: var(--cds-spacing-07); +} + +.sidebar-layout-shell { + transform: translate(0); + position: relative; +} + +.sidebar-layout-main { + margin-left: layout.rem(272px); +} + +.sidebar-header { + padding-left: var(--cds-spacing-05); +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.spec.ts new file mode 100644 index 00000000000..47e74a6f8c7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.spec.ts @@ -0,0 +1,26 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterTestingModule } from '@angular/router/testing'; +import { SideNavModule, ThemeModule } from 'carbon-components-angular'; +import { SidebarLayoutComponent } from './sidebar-layout.component'; + +describe('SidebarLayoutComponent', () => { + let component: SidebarLayoutComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [SidebarLayoutComponent], + imports: [RouterTestingModule, SideNavModule, ThemeModule] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(SidebarLayoutComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.ts new file mode 100644 index 00000000000..971a872df31 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.ts @@ -0,0 +1,22 @@ +import { Component, Input, ViewEncapsulation } from '@angular/core'; + +export interface SidebarItem { + label: string; + route: string[]; + routerLinkActiveOptions?: { exact: boolean }; +} + +@Component({ + selector: 'cd-sidebar-layout', + templateUrl: './sidebar-layout.component.html', + styleUrls: ['./sidebar-layout.component.scss'], + encapsulation: ViewEncapsulation.None, + standalone: false, + host: { + class: 'tearsheet--full' + } +}) +export class SidebarLayoutComponent { + @Input() title!: string; + @Input() items: SidebarItem[] = []; +} 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..7de826aa2e3 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', @@ -87,7 +88,8 @@ export enum ActionLabels { /* Multi-cluster */ CONNECT = 'connect', - RECONNECT = 'reconnect' + RECONNECT = 'reconnect', + VIEW = 'View' } @Injectable({ @@ -162,7 +164,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 +256,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 67f8da625c2..1e6b926ed88 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 @@ -104,7 +104,8 @@ export enum Icons { notification = 'notification', error = 'error--filled', notificationOff = 'notification--off', - notificationNew = 'notification--new' + notificationNew = 'notification--new', + emptySearch = 'search' } export enum IconSize { @@ -127,5 +128,6 @@ export const ICON_TYPE = { notificationNew: 'notification--new', success: 'success', warning: 'warning', - add: 'add' + add: 'add', + 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..816613dc595 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts @@ -0,0 +1,10 @@ +export enum NvmeofSubsystemAuthType { + NO_AUTH = 'No authentication', + UNIDIRECTIONAL = 'Unidirectional', + BIDIRECTIONAL = 'Bi-directional' +} + +export enum NvmeofGatewayNodeMode { + SELECTOR = 'selector', + DETAILS = 'details' +} 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 976435853ed..59bd8c1c725 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,10 +19,21 @@ export interface NvmeofSubsystem { namespace_count: number; subtype: string; max_namespaces: number; + allow_any_host?: boolean; + enable_ha?: boolean; + gw_group?: string; + initiator_count?: number; + psk?: string; +} + +export interface NvmeofSubsystemData extends NvmeofSubsystem { + auth?: string; + hosts?: number; } export interface NvmeofSubsystemInitiator { nqn: string; + dhchap_key?: string; } export interface NvmeofListener { 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 fa9cf5cb746..20155ce6bb5 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 @@ -65,3 +65,7 @@ .cds-mr-5 { margin-right: layout.$spacing-05; } + +.cds-pt-6 { + padding-top: layout.$spacing-06; +}