From: pujaoshahu Date: Tue, 20 Jan 2026 06:14:44 +0000 (+0530) Subject: mgr/dashboard: Fix nvmeof namespace list and delete modal X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=3724ab3e10f92988487a59f3c0bea8c4750c3a79;p=ceph.git mgr/dashboard: Fix nvmeof namespace list and delete modal Fixes: https://tracker.ceph.com/issues/74451 Signed-off-by: pujaoshahu Conflicts: src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts index 4446a2de9cb..b0316782099 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts @@ -12,6 +12,8 @@ import { FinishedTask } from '~/app/shared/models/finished-task'; import { ActivatedRoute, Router } from '@angular/router'; import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { Icons } from '~/app/shared/enum/icons.enum'; + @Component({ selector: 'cd-nvmeof-initiators-form', templateUrl: './nvmeof-initiators-form.component.html', @@ -19,6 +21,7 @@ import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service standalone: false }) export class NvmeofInitiatorsFormComponent implements OnInit { + icons = Icons; permission: Permission; initiatorForm: CdFormGroup; action: string; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html index 369c9b96acd..a600eadc582 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html @@ -1,24 +1,44 @@ - - - An NVMe namespace is a quantity of non-volatile storage that can be formatted into logical blocks and presented to a host as a standard block device. - - - - -
- - +
+
+
+ + +
- +
+
+ + + + +
+ + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts index 30775d21569..2345d290741 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts @@ -1,12 +1,13 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HttpClientModule } from '@angular/common/http'; +import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core'; import { of } from 'rxjs'; import { RouterTestingModule } from '@angular/router/testing'; 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 { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component'; import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list.component'; @@ -29,8 +30,16 @@ const mockNamespaces = [ ]; class MockNvmeOfService { - listNamespaces() { - return of(mockNamespaces); + listGatewayGroups() { + return of([[{ id: 'g1' }]]); + } + + formatGwGroupsList(_response: any) { + return [{ content: 'g1', selected: false }]; + } + + listNamespaces(_group?: string) { + return of({ namespaces: mockNamespaces }); } } @@ -40,7 +49,9 @@ class MockAuthStorageService { } } -class MockModalService {} +class MockModalCdsService { + show = jasmine.createSpy('show'); +} class MockTaskWrapperService {} @@ -48,6 +59,8 @@ describe('NvmeofNamespacesListComponent', () => { let component: NvmeofNamespacesListComponent; let fixture: ComponentFixture; + let modalService: MockModalCdsService; + beforeEach(async () => { await TestBed.configureTestingModule({ declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent], @@ -55,9 +68,10 @@ describe('NvmeofNamespacesListComponent', () => { providers: [ { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, - { provide: ModalService, useClass: MockModalService }, + { provide: ModalCdsService, useClass: MockModalCdsService }, { provide: TaskWrapperService, useClass: MockTaskWrapperService } - ] + ], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(NvmeofNamespacesListComponent); @@ -65,15 +79,36 @@ describe('NvmeofNamespacesListComponent', () => { component.ngOnInit(); component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436'; fixture.detectChanges(); + modalService = TestBed.inject(ModalCdsService) as any; }); it('should create', () => { expect(component).toBeTruthy(); }); - it('should retrieve namespaces', fakeAsync(() => { + it('should retrieve namespaces', (done) => { + component.group = 'g1'; + component.namespaces$.subscribe((namespaces) => { + expect(namespaces).toEqual(mockNamespaces); + done(); + }); component.listNamespaces(); - tick(); - expect(component.namespaces).toEqual(mockNamespaces); - })); + }); + + it('should open delete modal with correct data', () => { + // Mock selection + const namespace = { + nsid: 1, + ns_subsystem_nqn: 'nqn.2001-07.com.ceph:1721040751436' + }; + component.selection = { + first: () => namespace + } as any; + component.deleteNamespaceModal(); + expect(modalService.show).toHaveBeenCalled(); + const args = modalService.show.calls.mostRecent().args[1]; + expect(args.itemNames).toEqual([1]); + expect(args.itemDescription).toBeDefined(); + expect(typeof args.submitActionObservable).toBe('function'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts index 39f1cdc91a7..58853ad8cd5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts @@ -1,22 +1,25 @@ -import { Component, Input, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; -import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; import { Icons } from '~/app/shared/enum/icons.enum'; import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof'; import { Permission } from '~/app/shared/models/permissions'; +import { CephServiceSpec } from '~/app/shared/models/service.interface'; import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; -import { IopsPipe } from '~/app/shared/pipes/iops.pipe'; -import { MbpersecondPipe } from '~/app/shared/pipes/mbpersecond.pipe'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { BehaviorSubject, Observable, 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`; @Component({ selector: 'cd-nvmeof-namespaces-list', @@ -24,94 +27,66 @@ const BASE_URL = 'block/nvmeof/subsystems'; styleUrls: ['./nvmeof-namespaces-list.component.scss'], standalone: false }) -export class NvmeofNamespacesListComponent implements OnInit { +export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { @Input() subsystemNQN: string; @Input() group: string; - + @ViewChild('deleteTpl', { static: true }) + deleteTpl: TemplateRef; namespacesColumns: any; tableActions: CdTableAction[]; selection = new CdTableSelection(); permission: Permission; - namespaces: NvmeofSubsystemNamespace[]; + namespaces$: Observable; + private namespaceSubject = new BehaviorSubject(undefined); + + // Gateway group dropdown properties + gwGroups: GroupsComboboxItem[] = []; + gwGroupsEmpty: boolean = false; + gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER; + + private destroy$ = new Subject(); constructor( public actionLabels: ActionLabelsI18n, private router: Router, + private route: ActivatedRoute, private modalService: ModalCdsService, private authStorageService: AuthStorageService, private taskWrapper: TaskWrapperService, private nvmeofService: NvmeofService, - private dimlessBinaryPipe: DimlessBinaryPipe, - private mbPerSecondPipe: MbpersecondPipe, - private iopsPipe: IopsPipe + private dimlessBinaryPipe: DimlessBinaryPipe ) { this.permission = this.authStorageService.getPermissions().nvmeof; } ngOnInit() { + this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { + if (params?.['group']) this.onGroupSelection({ content: params?.['group'] }); + }); + this.setGatewayGroups(); this.namespacesColumns = [ { - name: $localize`ID`, + name: $localize`Namespace ID`, prop: 'nsid' }, { - name: $localize`Bdev Name`, - prop: 'bdev_name' - }, - { - name: $localize`Pool `, - prop: 'rbd_pool_name', - flexGrow: 2 - }, - { - name: $localize`Image`, - prop: 'rbd_image_name', - flexGrow: 3 - }, - { - name: $localize`Image Size`, + name: $localize`Size`, prop: 'rbd_image_size', pipe: this.dimlessBinaryPipe }, { - name: $localize`Block Size`, - prop: 'block_size', - pipe: this.dimlessBinaryPipe - }, - { - name: $localize`IOPS`, - prop: 'rw_ios_per_second', - sortable: false, - pipe: this.iopsPipe, - flexGrow: 1.5 - }, - { - name: $localize`R/W Throughput`, - prop: 'rw_mbytes_per_second', - sortable: false, - pipe: this.mbPerSecondPipe, - flexGrow: 1.5 - }, - { - name: $localize`Read Throughput`, - prop: 'r_mbytes_per_second', - sortable: false, - pipe: this.mbPerSecondPipe, - flexGrow: 1.5 + name: $localize`Pool`, + prop: 'rbd_pool_name' }, { - name: $localize`Write Throughput`, - prop: 'w_mbytes_per_second', - sortable: false, - pipe: this.mbPerSecondPipe, - flexGrow: 1.5 + name: $localize`Image`, + prop: 'rbd_image_name' }, { - name: $localize`Load Balancing Group`, - prop: 'load_balancing_group', - flexGrow: 1.5 + name: $localize`Subsystem`, + prop: 'ns_subsystem_nqn' } ]; this.tableActions = [ @@ -124,7 +99,8 @@ export class NvmeofNamespacesListComponent implements OnInit { [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }], { queryParams: { group: this.group } } ), - canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, + disable: () => !this.group }, { name: this.actionLabels.EDIT, @@ -155,6 +131,21 @@ export class NvmeofNamespacesListComponent implements OnInit { click: () => this.deleteNamespaceModal() } ]; + + this.namespaces$ = this.namespaceSubject.pipe( + switchMap(() => { + if (!this.group) { + return of([]); + } + return this.nvmeofService.listNamespaces(this.group).pipe( + map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) => { + return Array.isArray(res) ? res : res.namespaces || []; + }), + catchError(() => of([])) + ); + }), + takeUntil(this.destroy$) + ); } updateSelection(selection: CdTableSelection) { @@ -162,25 +153,89 @@ export class NvmeofNamespacesListComponent implements OnInit { } listNamespaces() { - this.nvmeofService.listNamespaces(this.group).subscribe((res: NvmeofSubsystemNamespace[]) => { - this.namespaces = res; - }); + this.namespaceSubject.next(); + } + + fetchData() { + this.namespaceSubject.next(); + } + + // Gateway groups methods + onGroupSelection(selected: GroupsComboboxItem) { + selected.selected = true; + this.group = selected.content; + this.listNamespaces(); + } + + onGroupClear() { + this.group = null; + this.listNamespaces(); + } + + setGatewayGroups() { + 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 if (!this.gwGroups.length) { + 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`; + if (error?.preventDefault) { + error?.preventDefault?.(); + } } deleteNamespaceModal() { const namespace = this.selection.first(); + const subsystemNqn = namespace.ns_subsystem_nqn; this.modalService.show(DeleteConfirmationModalComponent, { - itemDescription: 'Namespace', + itemDescription: $localize`Namespace`, + impact: DeletionImpact.high, + bodyTemplate: this.deleteTpl, itemNames: [namespace.nsid], actionDescription: 'delete', + bodyContext: { + deletionMessage: $localize`Deleting the namespace ${namespace.nsid} will permanently remove all resources, services, and configurations within it. This action cannot be undone.` + }, submitActionObservable: () => this.taskWrapper.wrapTaskAroundCall({ task: new FinishedTask('nvmeof/namespace/delete', { - nqn: this.subsystemNQN, + nqn: subsystemNqn, nsid: namespace.nsid }), - call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group) + call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group) }) }); } + + ngOnDestroy() { + this.destroy$.next(); + this.destroy$.complete(); + } } 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 f06bec2543f..737ba752660 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 @@ -1,46 +1,55 @@ -
- - - +
+
+
+ + + +
+
+ + - - -
- - -
+
+ + +
- - -
+ + +
+
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 bc3edd51522..a3532ec1e12 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 @@ -1,4 +1,4 @@ -import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HttpClientModule } from '@angular/common/http'; import { of } from 'rxjs'; import { RouterTestingModule } from '@angular/router/testing'; @@ -112,17 +112,19 @@ describe('NvmeofSubsystemsComponent', () => { expect(component).toBeTruthy(); }); - it('should retrieve subsystems', fakeAsync(() => { - component.getSubsystems(); - tick(); + it('should retrieve subsystems', (done) => { const expected = mockSubsystems.map((s) => ({ ...s, gw_group: component.group, auth: 'No authentication', initiator_count: 0 })); - expect(component.subsystems).toEqual(expected); - })); + component.subsystems$.subscribe((subsystems) => { + expect(subsystems).toEqual(expected); + done(); + }); + component.getSubsystems(); + }); it('should load gateway groups correctly', () => { expect(component.gwGroups.length).toBe(2); 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 d2207c2f577..468cf64de52 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 @@ -23,7 +23,7 @@ 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 { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs'; import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; @@ -46,7 +46,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit @ViewChild('deleteTpl', { static: true }) deleteTpl: TemplateRef; - subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = []; subsystemsColumns: any; permissions: Permissions; selection = new CdTableSelection(); @@ -58,6 +57,8 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit gwGroupsEmpty: boolean = false; gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER; authType = NvmeofSubsystemAuthType; + subsystems$: Observable<(NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[]>; + private subsystemSubject = new BehaviorSubject(undefined); private destroy$ = new Subject(); @@ -130,42 +131,43 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit click: () => this.deleteSubsystemModal() } ]; - } - - updateSelection(selection: CdTableSelection) { - this.selection = selection; - } - getSubsystems() { - if (this.group) { - this.nvmeofService - .listSubsystems(this.group) - .pipe( + this.subsystems$ = this.subsystemSubject.pipe( + switchMap(() => { + if (!this.group) { + return of([]); + } + return this.nvmeofService.listSubsystems(this.group).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 = []; + }), + catchError((error) => { this.notificationService.show( NotificationType.error, $localize`Unable to fetch Gateway group`, $localize`Gateway group does not exist` ); this.handleError(error); - } - }); - } else { - this.subsystems = []; - } + return of([]); + }) + ); + }), + takeUntil(this.destroy$) + ); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + getSubsystems() { + this.subsystemSubject.next(); + } + + fetchData() { + this.subsystemSubject.next(); } deleteSubsystemModal() {