From db4950dc8b72da4127d3a3ffef61594532f00dc3 Mon Sep 17 00:00:00 2001 From: Puja Shahu Date: Mon, 29 Dec 2025 13:27:06 +0530 Subject: [PATCH] mgr/dashboard: NVme-Subsystem list Fixes: https://tracker.ceph.com/issues/74284 Signed-off-by:pujaoshahu Signed-off-by: Puja Shahu Signed-off-by: pujaoshahu --- .../nvmeof-gateway-group.component.ts | 9 +- .../nvmeof-subsystems.component.html | 50 ++++-- .../nvmeof-subsystems.component.spec.ts | 15 +- .../nvmeof-subsystems.component.ts | 160 ++++++++++++++---- 4 files changed, 190 insertions(+), 44 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts index 5dcca32b006dd..b17aa751ce555 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts @@ -140,7 +140,14 @@ export class NvmeofGatewayGroupComponent implements OnInit { ); }), catchError((error) => { - this.context?.error?.(error); + this.notificationService.show( + NotificationType.error, + $localize`Unable to fetch Gateway group`, + $localize`Gateway group does not exist` + ); + if (error?.preventDefault) { + error.preventDefault(); + } return of([]); }) ) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html index 84a1c8763b1b1..c9956b82e8cce 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html @@ -14,16 +14,10 @@ - - Subsystems - - A subsystem provides access control to which hosts can access the namespaces within the subsystem. - - - + [group]="expandedRow?.gw_group"> + +
+ @if (row.enable_ha === false) { + + No authentication + } @else if (row.allow_any_host) { + + Unidirectional + } @else { + + Bidirectional + } +
+
+ + +
+ @if (row.enable_ha) { + + Enabled + } @else { + + Disabled + } +
+
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts index 413cec31ce346..d2de1c4090577 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts @@ -6,7 +6,7 @@ import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofService } from '../../../shared/api/nvmeof.service'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; -import { ModalService } from '~/app/shared/services/modal.service'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component'; import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component'; @@ -63,6 +63,10 @@ class MockNvmeOfService { return of(mockSubsystems); } + getInitiators() { + return of([]); + } + formatGwGroupsList(_data: CephServiceSpec[][]) { return mockformattedGwGroups; } @@ -93,7 +97,7 @@ describe('NvmeofSubsystemsComponent', () => { providers: [ { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, - { provide: ModalService, useClass: MockModalService }, + { provide: ModalCdsService, useClass: MockModalService }, { provide: TaskWrapperService, useClass: MockTaskWrapperService } ] }).compileComponents(); @@ -111,7 +115,12 @@ describe('NvmeofSubsystemsComponent', () => { it('should retrieve subsystems', fakeAsync(() => { component.getSubsystems(); tick(); - expect(component.subsystems).toEqual(mockSubsystems); + const expected = mockSubsystems.map((s) => ({ + ...s, + gw_group: component.group, + initiator_count: 0 + })); + expect(component.subsystems).toEqual(expected); })); it('should load gateway groups correctly', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts index c725199d13f13..dc29124710205 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts @@ -1,20 +1,25 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; - import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { NvmeofSubsystem } from '~/app/shared/models/nvmeof'; +import { NvmeofSubsystem, NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof'; import { Permissions } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; import { CdTableAction } from '~/app/shared/models/cd-table-action'; + import { Icons } from '~/app/shared/enum/icons.enum'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { CephServiceSpec } from '~/app/shared/models/service.interface'; +import { forkJoin, of, Subject } from 'rxjs'; +import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; const BASE_URL = 'block/nvmeof/subsystems'; const DEFAULT_PLACEHOLDER = $localize`Enter group name`; @@ -25,18 +30,27 @@ const DEFAULT_PLACEHOLDER = $localize`Enter group name`; styleUrls: ['./nvmeof-subsystems.component.scss'], standalone: false }) -export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit { - subsystems: NvmeofSubsystem[] = []; +export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit, OnDestroy { + @ViewChild('authenticationTpl', { static: true }) + authenticationTpl: TemplateRef; + + @ViewChild('encryptionTpl', { static: true }) + encryptionTpl: TemplateRef; + + subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = []; subsystemsColumns: any; permissions: Permissions; selection = new CdTableSelection(); tableActions: CdTableAction[]; subsystemDetails: any[]; + context: CdTableFetchDataContext; gwGroups: GroupsComboboxItem[] = []; group: string = null; gwGroupsEmpty: boolean = false; gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER; + private destroy$ = new Subject(); + constructor( private nvmeofService: NvmeofService, private authStorageService: AuthStorageService, @@ -44,29 +58,46 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit private router: Router, private modalService: ModalCdsService, private taskWrapper: TaskWrapperService, - private route: ActivatedRoute + private route: ActivatedRoute, + private notificationService: NotificationService ) { super(); this.permissions = this.authStorageService.getPermissions(); } ngOnInit() { - this.route.queryParams.subscribe((params) => { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { if (params?.['group']) this.onGroupSelection({ content: params?.['group'] }); }); this.setGatewayGroups(); this.subsystemsColumns = [ { - name: $localize`NQN`, - prop: 'nqn' + name: $localize`Subsystem NQN`, + prop: 'nqn', + flexGrow: 2 }, { - name: $localize`# Namespaces`, + name: $localize`Gateway group`, + prop: 'gw_group' + }, + { + name: $localize`Initiators`, + prop: 'initiator_count' + }, + + { + name: $localize`Namespaces`, prop: 'namespace_count' }, { - name: $localize`# Maximum Allowed Namespaces`, - prop: 'max_namespaces' + name: $localize`Authentication`, + prop: 'authentication', + cellTemplate: this.authenticationTpl + }, + { + name: $localize`Traffic encryption`, + prop: 'encryption', + cellTemplate: this.encryptionTpl } ]; this.tableActions = [ @@ -90,7 +121,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit ]; } - // Subsystems updateSelection(selection: CdTableSelection) { this.selection = selection; } @@ -99,9 +129,28 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit if (this.group) { this.nvmeofService .listSubsystems(this.group) - .subscribe((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => { - if (Array.isArray(subsystems)) this.subsystems = subsystems; - else this.subsystems = [subsystems]; + .pipe( + switchMap((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => { + const subs = Array.isArray(subsystems) ? subsystems : [subsystems]; + if (subs.length === 0) return of([]); + + return forkJoin(subs.map((sub) => this.enrichSubsystemWithInitiators(sub))); + }) + ) + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (subsystems: NvmeofSubsystem[]) => { + this.subsystems = subsystems; + }, + error: (error) => { + this.subsystems = []; + this.notificationService.show( + NotificationType.error, + $localize`Unable to fetch Gateway group`, + $localize`Gateway group does not exist` + ); + this.handleError(error); + } }); } else { this.subsystems = []; @@ -135,19 +184,70 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit } setGatewayGroups() { - this.nvmeofService.listGatewayGroups().subscribe((response: CephServiceSpec[][]) => { - if (response?.[0]?.length) { - this.gwGroups = this.nvmeofService.formatGwGroupsList(response); - } else this.gwGroups = []; - // Select first group if no group is selected - if (!this.group && this.gwGroups.length) { - this.onGroupSelection(this.gwGroups[0]); - this.gwGroupsEmpty = false; - this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER; - } else { - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`No groups available`; - } - }); + this.nvmeofService + .listGatewayGroups() + .pipe(takeUntil(this.destroy$)) + .subscribe({ + next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response), + error: (error) => this.handleGatewayGroupsError(error) + }); + } + + handleGatewayGroupsSuccess(response: CephServiceSpec[][]) { + if (response?.[0]?.length) { + this.gwGroups = this.nvmeofService.formatGwGroupsList(response); + } else { + this.gwGroups = []; + } + this.updateGroupSelectionState(); + } + + updateGroupSelectionState() { + if (!this.group && this.gwGroups.length) { + this.onGroupSelection(this.gwGroups[0]); + this.gwGroupsEmpty = false; + this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER; + } else { + this.gwGroupsEmpty = true; + this.gwGroupPlaceholder = $localize`No groups available`; + } + } + + handleGatewayGroupsError(error: any) { + this.gwGroups = []; + this.gwGroupsEmpty = true; + this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`; + this.handleError(error); + } + + private handleError(error: any): void { + if (error?.preventDefault) { + error?.preventDefault?.(); + } + this.context?.error?.(error); + } + + private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) { + return this.nvmeofService.getInitiators(sub.nqn, this.group).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; + } + + return { + ...sub, + gw_group: this.group, + initiator_count: count + } as NvmeofSubsystem & { initiator_count?: number }; + }) + ); + } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); } } -- 2.47.3