]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add option should be enabled even if allow all host access is enabled... 68180/head
authorpujaoshahu <pshahu@redhat.com>
Thu, 2 Apr 2026 11:24:06 +0000 (16:54 +0530)
committerpujashahu <pshahu@redhat.com>
Tue, 30 Jun 2026 09:48:13 +0000 (15:18 +0530)
Fixes: https://tracker.ceph.com/issues/75509
Signed-off-by: pujaoshahu <pshahu@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts

index 4b2df6992c5a1a90b4f65d788a1aa91f1faa933c..a4c8a8a1a35e089b74a5046fc33f823f0cc0d794 100644 (file)
@@ -13,6 +13,7 @@
       #tearsheetStep
                modal-primary-focus
       [group]="group"
+      [allowAllHosts]="allowAllHosts"
       [existingHosts]="existingHosts"></cd-nvmeof-subsystem-step-two>
   </cd-tearsheet-step>
   @if(showAuthStep) {
index e7f8f1f7251f670040fb4660b969ae2cec04215e..388592eaea426cf18a0927fc4e0543558779646a 100644 (file)
@@ -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');
index d25ffa1368a604177ac113861ba1aa93bbc42ac7..d3cb2308f39db03c26c0f1200ba6b9ddf83333c9 100644 (file)
@@ -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<HostStepType, 'hostType' | 'addedHosts'> &
@@ -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 }];
 
index 8c924ce015221ce6b45b4bf7c357c8e2d4fcd7a6..0988d0563d57a29691166963604edae7d230d7b9 100644 (file)
@@ -1,35 +1,43 @@
-@if (hasAllHostsAllowed()) {
+@if (allowAllHosts) {
   <cd-alert-panel
     class="cds-mb-4"
-    type="warning"
+    type="info"
     [showTitle]="false">
-    <div cdsStack="vertical"
-         gap="1">
-      <strong class="cds-mb-1"
-              i18n>All hosts allowed</strong>
-      <p class="cds-mb-0"
-         i18n>
-        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.
-      </p>
+    <div cdsStack="horizontal"
+         gap="2">
+      <div cdsStack="vertical"
+           gap="2">
+        <strong i18n>Host access: All hosts allowed</strong>
+        <p
+          class="cds-mb-0 cds-mt-1"
+          i18n
+        >
+          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.
+        </p>
+      </div>
+      <a cdsLink
+         class="cds-ml-3"
+         (click)="openAddInitiatorForm(true)"
+         i18n>Edit host access</a>
     </div>
   </cd-alert-panel>
+} @else {
+  <cd-table [data]="initiators"
+            columnMode="flex"
+            (fetchData)="listInitiators()"
+            [columns]="initiatorColumns"
+            selectionType="multiClick"
+            (updateSelection)="updateSelection($event)">
+    <div class="table-actions">
+      <cd-table-actions [permission]="permission"
+                        [selection]="selection"
+                        class="btn-group"
+                        [tableActions]="tableActions">
+      </cd-table-actions>
+    </div>
+  </cd-table>
 }
 
-<cd-table [data]="initiators"
-          columnMode="flex"
-          (fetchData)="listInitiators()"
-          [columns]="initiatorColumns"
-          selectionType="multiClick"
-          (updateSelection)="updateSelection($event)">
-  <div class="table-actions">
-    <cd-table-actions [permission]="permission"
-                      [selection]="selection"
-                      class="btn-group"
-                      [tableActions]="tableActions">
-    </cd-table-actions>
-  </div>
-</cd-table>
-
 <ng-template #hostNqnTpl
              let-row="data.row">
   {{ getDisplayedHostNqn(row.nqn) }}
index 05d956256cbebff42b6b02fd5c3446f505e9c184..0f72f0225b6f0ad321ce488cb2db3a10231986c8 100644 (file)
@@ -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<NvmeofInitiatorsListComponent>;
+  let nvmeofService: NvmeofService;
+  let routeParams$: Subject<any>;
+  let queryParams$: Subject<any>;
+  let routerEvents$: Subject<any>;
 
   beforeEach(async () => {
+    routeParams$ = new Subject<any>();
+    queryParams$ = new Subject<any>();
+    routerEvents$ = new Subject<any>();
+
     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 }]);
+  }));
 });
index 4aa5fccb4603d06b4da065225b77f160fc11f623..7f4461e96e0ef06fbf5d5b8c831c4704fff882c4 100644 (file)
@@ -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();
+        })
+      );
+    }
   }
 }
index 6ac05c63b699f3d1459a18950096987b08733375..a259edf2bc42aa9bc07b1e560db9156d8ec8e6aa 100644 (file)
@@ -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<NvmeofNamespacesListComponent>;
 
   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();
+  });
 });
index a733709627f6f2d4690efc11e9e4bc5c052653ab..b06f9548b764a0ff70c843124153178e9c36c907 100644 (file)
@@ -23,6 +23,7 @@
           orientation="vertical">
           <cds-radio
             [value]="HOST_TYPE.ALL"
+            [disabled]="!allowAllHosts"
             class="cds-mb-2"
             i18n>Allow all hosts</cds-radio>
           <span class="cds--form__helper-text cds-mb-3"
index 70cfbd75a31239c870972ed990baccbaec9857bf..3829ed3d201956fdc4a7a8b372edc4bd9752c14b 100644 (file)
@@ -20,6 +20,7 @@ import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
 export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep {
   @Input() group!: string;
   @Input() existingHosts: string[] = [];
+  @Input() allowAllHosts = true;
   @ViewChild('rightInfluencer', { static: true })
   rightInfluencer?: TemplateRef<any>;
   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<string>();
 
   constructor(
index a3532ec1e12e420d08409a8bbbf9a9246d7833e4..3b061f2bf004693bbc70ea6474c92036be52cc56 100644 (file)
@@ -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();
+  });
 });