From: pujaoshahu Date: Thu, 2 Apr 2026 11:24:06 +0000 (+0530) Subject: mgr/dashboard: Add option should be enabled even if allow all host access is enabled... X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=fcac016ae429dd8d64f00fe3639582b547cc7e64;p=ceph.git mgr/dashboard: Add option should be enabled even if allow all host access is enabled in nvme/tcp Fixes: https://tracker.ceph.com/issues/75509 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html index 4b2df6992c5..a4c8a8a1a35 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html @@ -13,6 +13,7 @@ #tearsheetStep modal-primary-focus [group]="group" + [allowAllHosts]="allowAllHosts" [existingHosts]="existingHosts"> @if(showAuthStep) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts index e7f8f1f7251..388592eaea4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts @@ -2,7 +2,7 @@ import { HttpClientTestingModule } from '@angular/common/http/testing'; import { ReactiveFormsModule } from '@angular/forms'; import { RouterTestingModule } from '@angular/router/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { ActivatedRoute } from '@angular/router'; +import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { of } from 'rxjs'; @@ -31,7 +31,7 @@ describe('NvmeofInitiatorsFormComponent', () => { { provide: ActivatedRoute, useValue: { - queryParams: of({ group: 'test-group' }), + queryParamMap: of(convertToParamMap({ group: 'test-group' })), params: of({ subsystem_nqn: 'nqn.test' }), parent: { params: of({ subsystem_nqn: 'nqn.test' }) @@ -59,6 +59,22 @@ describe('NvmeofInitiatorsFormComponent', () => { expect(component).toBeTruthy(); }); + it('should set allowAllHosts to true when disableAllowAll is not set', () => { + const router = TestBed.inject(Router); + spyOn(router, 'getCurrentNavigation').and.returnValue(null); + component.ngOnInit(); + expect(component.allowAllHosts).toBe(true); + }); + + it('should set allowAllHosts to false when disableAllowAll is true in navigation state', () => { + const router = TestBed.inject(Router); + spyOn(router, 'getCurrentNavigation').and.returnValue({ + extras: { state: { disableAllowAll: true } } + } as any); + component.ngOnInit(); + expect(component.allowAllHosts).toBe(false); + }); + it('should initialize with two steps (Host access control + Authentication optional)', () => { expect(component.steps.length).toBe(2); expect(component.steps[0].label).toBe('Host access control'); 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 d25ffa1368a..d3cb2308f39 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 @@ -9,7 +9,7 @@ import { NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; -import { ActivatedRoute, Router } from '@angular/router'; +import { ActivatedRoute, Params, Router } from '@angular/router'; import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; type InitiatorsFormPayload = Pick & @@ -32,6 +32,7 @@ export class NvmeofInitiatorsFormComponent implements OnInit { isSubmitLoading = false; existingHosts: string[] = []; showAuthStep = true; + allowAllHosts = true; stepTwoValue: HostStepType = null; @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent; @@ -50,15 +51,16 @@ export class NvmeofInitiatorsFormComponent implements OnInit { ) {} ngOnInit() { - this.route.queryParams.subscribe((params) => { - this.group = params?.['group']; + this.route.queryParamMap.subscribe((params) => { + this.group = params.get('group'); }); - this.route.parent.params.subscribe((params: any) => { + this.allowAllHosts = !this.getDisableAllowAllState(); + this.route.parent.params.subscribe((params: Params) => { if (params.subsystem_nqn) { this.subsystemNQN = params.subsystem_nqn; } }); - this.route.params.subscribe((params: any) => { + this.route.params.subscribe((params: Params) => { if (!this.subsystemNQN && params.subsystem_nqn) { this.subsystemNQN = params.subsystem_nqn; } @@ -67,6 +69,10 @@ export class NvmeofInitiatorsFormComponent implements OnInit { this.rebuildSteps(); } + private getDisableAllowAllState(): boolean { + return this.router.getCurrentNavigation()?.extras?.state?.['disableAllowAll'] === true; + } + rebuildSteps() { const steps: Step[] = [{ label: STEP_LABELS.HOSTS, invalid: false }]; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html index 8c924ce0152..0988d0563d5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html @@ -1,35 +1,43 @@ -@if (hasAllHostsAllowed()) { +@if (allowAllHosts) { -
- All hosts allowed -

- Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access. -

+
+
+ Host access: All hosts allowed +

+ Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access. +

+
+ Edit host access
+} @else { + +
+ + +
+
} - -
- - -
-
- {{ getDisplayedHostNqn(row.nqn) }} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts index 05d956256cb..0f72f0225b6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts @@ -1,13 +1,14 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { HttpClientModule } from '@angular/common/http'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; import { RouterTestingModule } from '@angular/router/testing'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { SharedModule } from '~/app/shared/shared.module'; import { NvmeofService } from '~/app/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 { ALLOW_ALL_HOST } from '~/app/shared/models/nvmeof'; @@ -15,7 +16,7 @@ import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list.componen const mockInitiators = [ { - nqn: ALLOW_ALL_HOST, + nqn: 'nqn.2016-06.io.spdk:host1', use_dhchap: false } ]; @@ -23,7 +24,8 @@ const mockInitiators = [ const mockSubsystem = { nqn: 'nqn.2016-06.io.spdk:cnode1', serial_number: '12345', - has_dhchap_key: false + has_dhchap_key: false, + allow_any_host: false }; class MockNvmeOfService { @@ -41,26 +43,51 @@ class MockAuthStorageService { } } -class MockModalService {} +class MockModalCdsService {} class MockTaskWrapperService {} describe('NvmeofInitiatorsListComponent', () => { let component: NvmeofInitiatorsListComponent; let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + let routeParams$: Subject; + let queryParams$: Subject; + let routerEvents$: Subject; beforeEach(async () => { + routeParams$ = new Subject(); + queryParams$ = new Subject(); + routerEvents$ = new Subject(); + await TestBed.configureTestingModule({ declarations: [NvmeofInitiatorsListComponent], imports: [HttpClientModule, RouterTestingModule, SharedModule], providers: [ { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, - { provide: ModalService, useClass: MockModalService }, - { provide: TaskWrapperService, useClass: MockTaskWrapperService } + { provide: ModalCdsService, useClass: MockModalCdsService }, + { provide: TaskWrapperService, useClass: MockTaskWrapperService }, + { + provide: ActivatedRoute, + useValue: { + parent: { params: routeParams$.asObservable() }, + queryParams: queryParams$.asObservable() + } + }, + { + provide: Router, + useValue: { + events: routerEvents$.asObservable(), + navigate: jasmine.createSpy('navigate') + } + } ] }).compileComponents(); + }); + beforeEach(() => { + nvmeofService = TestBed.inject(NvmeofService); fixture = TestBed.createComponent(NvmeofInitiatorsListComponent); component = fixture.componentInstance; component.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1'; @@ -84,9 +111,23 @@ describe('NvmeofInitiatorsListComponent', () => { expect(component.getDisplayedHostNqn(ALLOW_ALL_HOST)).toBe('Any'); })); + it('should default allowAllHosts to false', () => { + expect(component.allowAllHosts).toBe(false); + }); + + it('should set allowAllHosts to true when subsystem allows any host and no initiators', fakeAsync(() => { + const allowAllSubsystem = { ...mockSubsystem, allow_any_host: true }; + spyOn(nvmeofService, 'getInitiators').and.returnValue(of([])); + spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(allowAllSubsystem)); + component.listInitiators(); + component.getSubsystem(); + tick(); + expect(component.allowAllHosts).toBe(true); + })); + it('should update authStatus when initiator has dhchap_key', fakeAsync(() => { const initiatorsWithKey = [{ nqn: 'nqn1', use_dhchap: true }]; - spyOn(TestBed.inject(NvmeofService), 'getInitiators').and.returnValue(of(initiatorsWithKey)); + spyOn(nvmeofService, 'getInitiators').and.returnValue(of(initiatorsWithKey)); component.listInitiators(); tick(); expect(component.authStatus).toBe('Unidirectional'); @@ -96,9 +137,76 @@ describe('NvmeofInitiatorsListComponent', () => { const initiatorsWithKey = [{ nqn: 'nqn1', use_dhchap: true }]; component.initiators = initiatorsWithKey; const subsystemWithKey = { ...mockSubsystem, has_dhchap_key: true }; - spyOn(TestBed.inject(NvmeofService), 'getSubsystem').and.returnValue(of(subsystemWithKey)); + spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(subsystemWithKey)); component.getSubsystem(); tick(); expect(component.authStatus).toBe('Bi-directional'); })); + + it('should fetch only when both route and query params are available', () => { + const newFixture = TestBed.createComponent(NvmeofInitiatorsListComponent); + const newComponent = newFixture.componentInstance; + newComponent.subsystemNQN = undefined; + newComponent.group = undefined; + const listInitiatorsSpy = spyOn(newComponent, 'listInitiators'); + const getSubsystemSpy = spyOn(newComponent, 'getSubsystem'); + + newComponent.ngOnInit(); + + routeParams$.next({ subsystem_nqn: 'nqn.from.route' }); + expect(listInitiatorsSpy).not.toHaveBeenCalled(); + expect(getSubsystemSpy).not.toHaveBeenCalled(); + + queryParams$.next({ group: 'group-from-query' }); + expect(listInitiatorsSpy).toHaveBeenCalledTimes(1); + expect(getSubsystemSpy).toHaveBeenCalledTimes(1); + }); + + it('should refresh on non-modal navigation changes', () => { + const newFixture = TestBed.createComponent(NvmeofInitiatorsListComponent); + const newComponent = newFixture.componentInstance; + newComponent.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1'; + newComponent.group = 'group1'; + const listInitiatorsSpy = spyOn(newComponent, 'listInitiators'); + const getSubsystemSpy = spyOn(newComponent, 'getSubsystem'); + + newComponent.ngOnInit(); + routerEvents$.next(new NavigationEnd(1, '/nvmeof/(modal:add)', '/nvmeof/(modal:add)')); + expect(listInitiatorsSpy).toHaveBeenCalledTimes(1); + expect(getSubsystemSpy).toHaveBeenCalledTimes(1); + + routerEvents$.next(new NavigationEnd(2, '/nvmeof/subsystem', '/nvmeof/subsystem')); + expect(listInitiatorsSpy).toHaveBeenCalledTimes(2); + expect(getSubsystemSpy).toHaveBeenCalledTimes(2); + }); + + it('should filter ALLOW_ALL_HOST from array response', fakeAsync(() => { + spyOn(nvmeofService, 'getInitiators').and.returnValue( + of([ + { nqn: ALLOW_ALL_HOST, use_dhchap: false }, + { nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false } + ]) + ); + + component.listInitiators(); + tick(); + + expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false }]); + })); + + it('should support hosts-wrapper response from getInitiators', fakeAsync(() => { + spyOn(nvmeofService, 'getInitiators').and.returnValue( + of({ + hosts: [ + { nqn: ALLOW_ALL_HOST, use_dhchap: false }, + { nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true } + ] + }) + ); + + component.listInitiators(); + tick(); + + expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true }]); + })); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts index 4aa5fccb460..7f4461e96e0 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts @@ -1,5 +1,7 @@ -import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { Subscription } from 'rxjs'; +import { filter } from 'rxjs/operators'; import { NvmeofService } 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'; @@ -28,7 +30,7 @@ import { NvmeofEditHostKeyModalComponent } from '../nvmeof-edit-host-key-modal/n styleUrls: ['./nvmeof-initiators-list.component.scss'], standalone: false }) -export class NvmeofInitiatorsListComponent implements OnInit { +export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy { @Input() subsystemNQN: string; @Input() @@ -50,6 +52,9 @@ export class NvmeofInitiatorsListComponent implements OnInit { allowAllHost = ALLOW_ALL_HOST; yesLabel = $localize`Yes`; noLabel = $localize`No`; + allowAllHosts = false; + + private subscriptions = new Subscription(); constructor( public actionLabels: ActionLabelsI18n, @@ -78,9 +83,23 @@ export class NvmeofInitiatorsListComponent implements OnInit { this.fetchIfReady(); }); } else { + this.listInitiators(); this.getSubsystem(); } + this.subscriptions.add( + this.router.events + .pipe( + filter( + (event): event is NavigationEnd => + event instanceof NavigationEnd && !event.urlAfterRedirects.includes('(modal:') + ) + ) + .subscribe(() => { + this.fetchIfReady(); + }) + ); + this.initiatorColumns = [ { name: $localize`Host NQN`, @@ -98,12 +117,9 @@ export class NvmeofInitiatorsListComponent implements OnInit { name: this.actionLabels.ADD, permission: 'create', icon: Icons.add, - click: () => - this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], { - queryParams: { group: this.group }, - relativeTo: this.route.parent - }), - canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + click: () => this.openAddInitiatorForm(), + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection, + disable: () => this.hasAllHostsAllowed() }, { name: $localize`Edit host key`, @@ -124,6 +140,10 @@ export class NvmeofInitiatorsListComponent implements OnInit { ]; } + ngOnDestroy() { + this.subscriptions.unsubscribe(); + } + private fetchIfReady() { if (this.subsystemNQN && this.group) { this.listInitiators(); @@ -131,15 +151,31 @@ export class NvmeofInitiatorsListComponent implements OnInit { } } + openAddInitiatorForm(disableAllowAll = false) { + this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], { + queryParams: { group: this.group }, + state: { disableAllowAll }, + relativeTo: this.route.parent + }); + } + editHostKeyModal() { const selected = this.selection.selected[0]; if (!selected) return; - this.modalService.show(NvmeofEditHostKeyModalComponent, { + const modalRef = this.modalService.show(NvmeofEditHostKeyModalComponent, { subsystemNQN: this.subsystemNQN, hostNQN: selected.nqn, group: this.group, dhchapKey: selected.dhchap_key || '' }); + if (modalRef?.closeChange) { + this.subscriptions.add( + modalRef.closeChange.subscribe(() => { + this.listInitiators(); + this.getSubsystem(); + }) + ); + } } getAllowAllHostIndex() { @@ -147,7 +183,7 @@ export class NvmeofInitiatorsListComponent implements OnInit { } hasAllHostsAllowed(): boolean { - return this.initiators.some((initiator) => initiator.nqn === ALLOW_ALL_HOST); + return !!this.subsystem?.allow_any_host && this.initiators.length === 0; } updateSelection(selection: CdTableSelection) { @@ -159,19 +195,22 @@ export class NvmeofInitiatorsListComponent implements OnInit { .getInitiators(this.subsystemNQN, this.group) .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => { const initiators = Array.isArray(response) ? response : response?.hosts || []; - this.initiators = initiators; + this.initiators = initiators.filter((i) => i.nqn !== ALLOW_ALL_HOST); this.updateAuthStatus(); }); } getSubsystem() { - this.nvmeofService.getSubsystem(this.subsystemNQN, this.group).subscribe((subsystem: any) => { - this.subsystem = subsystem; - this.updateAuthStatus(); - }); + this.nvmeofService + .getSubsystem(this.subsystemNQN, this.group) + .subscribe((subsystem: NvmeofSubsystem) => { + this.subsystem = subsystem; + this.updateAuthStatus(); + }); } updateAuthStatus() { + this.allowAllHosts = this.hasAllHostsAllowed(); if (this.subsystem && this.initiators) { this.authStatus = getSubsystemAuthStatus(this.subsystem, this.initiators); } @@ -195,7 +234,7 @@ export class NvmeofInitiatorsListComponent implements OnInit { itemNames = [...hostNQNs, $localize`Allow any host(*)`]; } const hostName = itemNames[0]; - this.modalService.show(DeleteConfirmationModalComponent, { + const deleteModalRef = this.modalService.show(DeleteConfirmationModalComponent, { itemDescription: $localize`host`, impact: DeletionImpact.high, itemNames, @@ -215,5 +254,13 @@ export class NvmeofInitiatorsListComponent implements OnInit { }) }) }); + if (deleteModalRef?.closeChange) { + this.subscriptions.add( + deleteModalRef.closeChange.subscribe(() => { + this.listInitiators(); + this.getSubsystem(); + }) + ); + } } } 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 6ac05c63b69..a259edf2bc4 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 @@ -31,8 +31,11 @@ const mockNamespaces = [ ]; class MockNvmeOfService { + gatewayGroupsResponse: any = [[{ id: 'g1' }]]; + namespacesResponse: any = { namespaces: mockNamespaces }; + listGatewayGroups() { - return of([[{ id: 'g1' }]]); + return of(this.gatewayGroupsResponse); } formatGwGroupsList(_response: any) { @@ -40,7 +43,7 @@ class MockNvmeOfService { } listNamespaces(_group?: string) { - return of({ namespaces: mockNamespaces }); + return of(this.namespacesResponse); } } @@ -61,6 +64,7 @@ describe('NvmeofNamespacesListComponent', () => { let fixture: ComponentFixture; let modalService: MockModalCdsService; + let nvmeofService: MockNvmeOfService; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -81,6 +85,7 @@ describe('NvmeofNamespacesListComponent', () => { component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436'; fixture.detectChanges(); modalService = TestBed.inject(ModalCdsService) as any; + nvmeofService = TestBed.inject(NvmeofService) as any; }); it('should create', () => { @@ -117,4 +122,47 @@ describe('NvmeofNamespacesListComponent', () => { expect(args.itemDescription).toBeDefined(); expect(typeof args.submitActionObservable).toBe('function'); }); + + it('should deduplicate namespaces by nsid and subsystem nqn', (done) => { + component.group = 'g1'; + nvmeofService.namespacesResponse = { + namespaces: [ + { nsid: 1, ns_subsystem_nqn: 'sub1' }, + { nsid: 1, ns_subsystem_nqn: 'sub1' }, + { nsid: 1, ns_subsystem_nqn: 'sub2' } + ] + }; + + component.namespaces$.pipe(take(1)).subscribe((namespaces) => { + expect(namespaces).toEqual([ + { nsid: 1, ns_subsystem_nqn: 'sub1', unique_id: '1_sub1' }, + { nsid: 1, ns_subsystem_nqn: 'sub2', unique_id: '1_sub2' } + ]); + done(); + }); + + component.listNamespaces(); + }); + + it('should default to first group and keep default placeholder when groups exist', () => { + component.group = null; + component.gwGroups = [{ content: 'g1', selected: false }] as any; + + component.updateGroupSelectionState(); + + expect(component.group).toBe('g1'); + expect(component.gwGroupsEmpty).toBe(false); + expect(component.gwGroupPlaceholder).toBe('Enter group name'); + }); + + it('should set error placeholder and call preventDefault on group fetch failure', () => { + const preventDefault = jasmine.createSpy('preventDefault'); + + component.handleGatewayGroupsError({ preventDefault }); + + expect(component.gwGroups).toEqual([]); + expect(component.gwGroupsEmpty).toBe(true); + expect(component.gwGroupPlaceholder).toBe('Unable to fetch Gateway groups'); + expect(preventDefault).toHaveBeenCalled(); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html index a733709627f..b06f9548b76 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html @@ -23,6 +23,7 @@ orientation="vertical"> Allow all hosts ; formGroup: CdFormGroup; @@ -36,7 +37,6 @@ export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep { csvDropText: string = $localize`Drag and drop files here or click to upload`; NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/; NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; - ALLOW_ALL_HOST = '*'; uploadedHosts = new Set(); constructor( 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 a3532ec1e12..3b061f2bf00 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 @@ -133,4 +133,39 @@ describe('NvmeofSubsystemsComponent', () => { it('should set first group as default initially', () => { expect(component.group).toBe(mockGroups[0][0].spec.group); }); + + it('should mark only current group as selected', () => { + component.group = 'foo'; + component.gwGroups = [{ content: 'default' }, { content: 'foo' }] as any; + + component.updateGroupSelectionState(); + + expect(component.gwGroups).toEqual([ + { content: 'default', selected: false }, + { content: 'foo', selected: true } + ]); + }); + + it('should clear selected group and refresh subsystem list', () => { + component.group = 'default'; + const getSubsystemsSpy = spyOn(component, 'getSubsystems'); + + component.onGroupClear(); + + expect(component.group).toBeNull(); + expect(getSubsystemsSpy).toHaveBeenCalled(); + }); + + it('should set error placeholder and prevent default on groups load error', () => { + const preventDefault = jasmine.createSpy('preventDefault'); + component.context = { error: jasmine.createSpy('error') } as any; + + component.handleGatewayGroupsError({ preventDefault }); + + expect(component.gwGroups).toEqual([]); + expect(component.gwGroupsEmpty).toBe(true); + expect(component.gwGroupPlaceholder).toBe('Unable to fetch Gateway groups'); + expect(preventDefault).toHaveBeenCalled(); + expect(component.context.error).toHaveBeenCalled(); + }); });