]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: NVMe-oF – Enable editing host access from the Subsystem Overview page 69917/head
authorpujashahu <pshahu@redhat.com>
Thu, 2 Jul 2026 13:35:52 +0000 (19:05 +0530)
committerPuja Shahu <pshahu@redhat.com>
Mon, 6 Jul 2026 08:21:55 +0000 (13:51 +0530)
Fixes: https://tracker.ceph.com/issues/77912
Signed-off-by: pujaoshahu <pshahu@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form/nvmeof-group-form.component.ts
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-subsystem-overview/nvmeof-subsystem-overview.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-overview/nvmeof-subsystem-overview.component.ts

index 5b77ae89ceb200b68e3f481c58332f1577befa4e..a4979e2c93df1363f2f6af565f379ee5a0496288 100644 (file)
@@ -160,7 +160,7 @@ describe('NvmeofGroupFormComponent', () => {
       } as any;
 
       component.groupForm.get('groupName').setValue('encrypted-group');
-      component.groupForm.get('enableEncryption').setValue(true);
+      component.groupForm.get('enable_auth').setValue(true);
       component.groupForm.get('encryptionKey').setValue('encryption-key-123');
       component.onSubmit();
 
index 7053bbbe9f9a99555707279f7c757cc7effffdac..583626b7bd1097ea33a15f51ea001d500644b08b 100644 (file)
@@ -68,6 +68,7 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
         [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
       ),
       unmanaged: new UntypedFormControl(false),
+      enable_auth: new UntypedFormControl(false),
       enableEncryption: new UntypedFormControl(false),
       encryptionConfig: new UntypedFormControl(null),
       encryptionKey: new UntypedFormControl(null),
@@ -158,7 +159,7 @@ export class NvmeofGroupFormComponent extends CdForm implements OnInit {
       unmanaged: formValues.unmanaged
     };
 
-    if (formValues.enableEncryption) {
+    if (formValues.enableEncryption || formValues.enable_auth) {
       const encryptionKey = formValues.encryptionKey || formValues.encryptionConfig;
       if (encryptionKey) {
         serviceSpec['encryption_key'] = encryptionKey;
index 388592eaea426cf18a0927fc4e0543558779646a..702c976954abadbf2a60e040ea8cca55643da50c 100644 (file)
@@ -10,7 +10,7 @@ import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
 
 import { SharedModule } from '~/app/shared/shared.module';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { ALLOW_ALL_HOST, HOST_TYPE } from '~/app/shared/models/nvmeof';
 
 import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form.component';
 
@@ -92,6 +92,7 @@ describe('NvmeofInitiatorsFormComponent', () => {
     beforeEach(() => {
       nvmeofService = TestBed.inject(NvmeofService);
       spyOn(nvmeofService, 'addSubsystemInitiators').and.returnValue(of({}));
+      spyOn(nvmeofService, 'removeInitiators').and.returnValue(of({}));
     });
 
     it('should be creating request correctly', () => {
@@ -147,5 +148,29 @@ describe('NvmeofInitiatorsFormComponent', () => {
       expect(nvmeofService.addSubsystemInitiators).not.toHaveBeenCalled();
       expect(component.isSubmitLoading).toBe(false);
     });
+
+    it('should remove wildcard host before adding specific hosts', () => {
+      const subsystemNQN = 'nqn.test';
+      component.subsystemNQN = subsystemNQN;
+      component.group = 'test-group';
+      component.existingHosts = [ALLOW_ALL_HOST];
+
+      const payload: any = {
+        hostType: HOST_TYPE.SPECIFIC,
+        addedHosts: ['host3']
+      };
+
+      component.onSubmit(payload);
+
+      expect(nvmeofService.removeInitiators).toHaveBeenCalledWith(subsystemNQN, {
+        host_nqn: ALLOW_ALL_HOST,
+        gw_group: 'test-group'
+      });
+      expect(nvmeofService.addSubsystemInitiators).toHaveBeenCalledWith(subsystemNQN, {
+        allow_all: false,
+        gw_group: 'test-group',
+        hosts: [{ dhchap_key: '', host_nqn: 'host3' }]
+      });
+    });
   });
 });
index d3cb2308f39db03c26c0f1200ba6b9ddf83333c9..789054cc746019f6d713e2ab513309d545ee46aa 100644 (file)
@@ -3,6 +3,7 @@ import { Step } from 'carbon-components-angular';
 import { NvmeofService, SubsystemInitiatorRequest } from '~/app/shared/api/nvmeof.service';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import {
+  ALLOW_ALL_HOST,
   AuthStepType,
   HOST_TYPE,
   HostStepType,
@@ -11,6 +12,8 @@ import {
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { ActivatedRoute, Params, Router } from '@angular/router';
 import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
+import { Observable } from 'rxjs';
+import { switchMap } from 'rxjs/operators';
 
 type InitiatorsFormPayload = Pick<HostStepType, 'hostType' | 'addedHosts'> &
   Partial<Pick<AuthStepType, 'hostDchapKeyList'>>;
@@ -149,12 +152,27 @@ export class NvmeofInitiatorsFormComponent implements OnInit {
       hosts,
       gw_group: this.group
     };
+
+    const shouldRemoveAllowAllHost =
+      payload.hostType === HOST_TYPE.SPECIFIC && this.existingHosts.includes(ALLOW_ALL_HOST);
+
+    const submitCall$: Observable<unknown> = shouldRemoveAllowAllHost
+      ? this.nvmeofService
+          .removeInitiators(this.subsystemNQN, {
+            host_nqn: ALLOW_ALL_HOST,
+            gw_group: this.group
+          })
+          .pipe(
+            switchMap(() => this.nvmeofService.addSubsystemInitiators(this.subsystemNQN, request))
+          )
+      : this.nvmeofService.addSubsystemInitiators(this.subsystemNQN, request);
+
     this.taskWrapperService
       .wrapTaskAroundCall({
         task: new FinishedTask(taskUrl, {
           nqn: this.subsystemNQN
         }),
-        call: this.nvmeofService.addSubsystemInitiators(this.subsystemNQN, request)
+        call: submitCall$
       })
       .subscribe({
         error: () => {
index 0988d0563d57a29691166963604edae7d230d7b9..b97d6684bad7015655d4273328278ea1a0048963 100644 (file)
@@ -1,43 +1,35 @@
 @if (allowAllHosts) {
   <cd-alert-panel
-    class="cds-mb-4"
-    type="info"
-    [showTitle]="false">
-    <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>
+  class="cds-mb-4"
+  type="warning"
+  [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>
+</cd-alert-panel>
 }
 
+<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 0f72f0225b6f0ad321ce488cb2db3a10231986c8..61f5d39ac741a02368826acb2e638e485923457e 100644 (file)
@@ -180,7 +180,7 @@ describe('NvmeofInitiatorsListComponent', () => {
     expect(getSubsystemSpy).toHaveBeenCalledTimes(2);
   });
 
-  it('should filter ALLOW_ALL_HOST from array response', fakeAsync(() => {
+  it('should include ALLOW_ALL_HOST from array response', fakeAsync(() => {
     spyOn(nvmeofService, 'getInitiators').and.returnValue(
       of([
         { nqn: ALLOW_ALL_HOST, use_dhchap: false },
@@ -191,7 +191,10 @@ describe('NvmeofInitiatorsListComponent', () => {
     component.listInitiators();
     tick();
 
-    expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false }]);
+    expect(component.initiators).toEqual([
+      { nqn: ALLOW_ALL_HOST, use_dhchap: false },
+      { nqn: 'nqn.2016-06.io.spdk:host2', use_dhchap: false }
+    ]);
   }));
 
   it('should support hosts-wrapper response from getInitiators', fakeAsync(() => {
@@ -207,6 +210,23 @@ describe('NvmeofInitiatorsListComponent', () => {
     component.listInitiators();
     tick();
 
-    expect(component.initiators).toEqual([{ nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true }]);
+    expect(component.initiators).toEqual([
+      { nqn: ALLOW_ALL_HOST, use_dhchap: false },
+      { nqn: 'nqn.2016-06.io.spdk:host3', use_dhchap: true }
+    ]);
+  }));
+
+  it('should set allowAllHosts to true when wildcard initiator exists', fakeAsync(() => {
+    const allowAllSubsystem = { ...mockSubsystem, allow_any_host: true };
+    spyOn(nvmeofService, 'getInitiators').and.returnValue(
+      of([{ nqn: ALLOW_ALL_HOST, use_dhchap: false }])
+    );
+    spyOn(nvmeofService, 'getSubsystem').and.returnValue(of(allowAllSubsystem));
+
+    component.listInitiators();
+    component.getSubsystem();
+    tick();
+
+    expect(component.allowAllHosts).toBe(true);
   }));
 });
index 7f4461e96e0ef06fbf5d5b8c831c4704fff882c4..872d004ad738d1e4c7504b410b6ee370af9bde1e 100644 (file)
@@ -183,7 +183,11 @@ export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy {
   }
 
   hasAllHostsAllowed(): boolean {
-    return !!this.subsystem?.allow_any_host && this.initiators.length === 0;
+    return (
+      !!this.subsystem?.allow_any_host &&
+      (this.initiators.length === 0 ||
+        this.initiators.some((initiator) => initiator.nqn === ALLOW_ALL_HOST))
+    );
   }
 
   updateSelection(selection: CdTableSelection) {
@@ -195,7 +199,7 @@ export class NvmeofInitiatorsListComponent implements OnInit, OnDestroy {
       .getInitiators(this.subsystemNQN, this.group)
       .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => {
         const initiators = Array.isArray(response) ? response : response?.hosts || [];
-        this.initiators = initiators.filter((i) => i.nqn !== ALLOW_ALL_HOST);
+        this.initiators = initiators;
         this.updateAuthStatus();
       });
   }
index c975058fe7cc55558fb8aabb81d5f4baa3c8bbf9..922b0d0399080f689793077200962183d13320f5 100644 (file)
@@ -68,8 +68,7 @@
             <div class="cds--type-label-02">
               <span>{{ detail.value ? 'Allow all hosts' : 'Restrict to specific hosts' }}</span>
               <a cdsLink
-                 [routerLink]="['../hosts']"
-                 [queryParams]="{ group: groupName }"
+                 (click)="openEditHostAccessModal()"
                  [cdsStack]="'horizontal'"
                  class="cds-ml-2"
                  [gap]="1">
index f86a3acb95df88242768319d276d57b17562ca70..8c3557c6372e48c7fe655981cd7d8dc0285601dd 100644 (file)
@@ -1,14 +1,15 @@
 import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { RouterTestingModule } from '@angular/router/testing';
-import { ActivatedRoute } from '@angular/router';
-import { of } from 'rxjs';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
+import { of, Subject } from 'rxjs';
 
 import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap';
 import { GridModule, TilesModule } from 'carbon-components-angular';
 
 import { NvmeofSubsystemOverviewComponent } from './nvmeof-subsystem-overview.component';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { URLVerbs } from '~/app/shared/constants/app.constants';
 import { SharedModule } from '~/app/shared/shared.module';
 import { NvmeofSubsystem, NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof';
 
@@ -16,6 +17,9 @@ describe('NvmeofSubsystemOverviewComponent', () => {
   let component: NvmeofSubsystemOverviewComponent;
   let fixture: ComponentFixture<NvmeofSubsystemOverviewComponent>;
   let nvmeofService: NvmeofService;
+  let router: Router;
+  let activatedRoute: ActivatedRoute;
+  let routerEvents$: Subject<any>;
 
   const mockSubsystem: NvmeofSubsystem = {
     nqn: 'nqn.2016-06.io.spdk:cnode1',
@@ -79,6 +83,8 @@ describe('NvmeofSubsystemOverviewComponent', () => {
   }
 
   beforeEach(async () => {
+    routerEvents$ = new Subject<any>();
+
     await TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemOverviewComponent],
       imports: [
@@ -97,6 +103,13 @@ describe('NvmeofSubsystemOverviewComponent', () => {
             getSubsystem: jest.fn().mockReturnValue(of(mockSubsystem)),
             getInitiators: jest.fn().mockReturnValue(of([]))
           }
+        },
+        {
+          provide: Router,
+          useValue: {
+            events: routerEvents$.asObservable(),
+            navigate: jest.fn().mockResolvedValue(true)
+          }
         }
       ]
     }).compileComponents();
@@ -106,6 +119,8 @@ describe('NvmeofSubsystemOverviewComponent', () => {
     fixture = TestBed.createComponent(NvmeofSubsystemOverviewComponent);
     component = fixture.componentInstance;
     nvmeofService = TestBed.inject(NvmeofService);
+    router = TestBed.inject(Router);
+    activatedRoute = TestBed.inject(ActivatedRoute);
     fixture.detectChanges();
   });
 
@@ -189,4 +204,30 @@ describe('NvmeofSubsystemOverviewComponent', () => {
     });
     expect(f.nativeElement.textContent).toContain('Unidirectional');
   }));
+
+  it('should open host access edit modal route when edit is clicked', () => {
+    const navigateSpy = jest.spyOn(router, 'navigate').mockResolvedValue(true);
+    component.groupName = 'group1';
+    component.openEditHostAccessModal();
+
+    expect(navigateSpy).toHaveBeenCalledWith(
+      [{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }],
+      {
+        queryParams: { group: 'group1' },
+        relativeTo: activatedRoute.parent
+      }
+    );
+  });
+
+  it('should refresh subsystem on non-modal navigation end', () => {
+    (nvmeofService.getSubsystem as jest.Mock).mockClear();
+
+    routerEvents$.next(new NavigationEnd(1, '/nvmeof/(modal:add)', '/nvmeof/(modal:add)'));
+    expect(nvmeofService.getSubsystem).not.toHaveBeenCalled();
+
+    routerEvents$.next(
+      new NavigationEnd(2, '/nvmeof/subsystems/overview', '/nvmeof/subsystems/overview')
+    );
+    expect(nvmeofService.getSubsystem).toHaveBeenCalledWith('nqn.2016-06.io.spdk:cnode1', 'group1');
+  });
 });
index acb97b9a4e8ac273d28ed57d7028fb4e651f134f..84e1abee4a8dd7b237812776094c033dc84ec81a 100644 (file)
@@ -1,6 +1,7 @@
-import { Component, OnInit } from '@angular/core';
-import { ActivatedRoute } from '@angular/router';
-import { forkJoin } from 'rxjs';
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { ActivatedRoute, NavigationEnd, Router } from '@angular/router';
+import { forkJoin, Subscription } from 'rxjs';
+import { filter } from 'rxjs/operators';
 
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import {
@@ -9,6 +10,7 @@ import {
   NO_AUTH,
   getSubsystemAuthStatus
 } from '~/app/shared/models/nvmeof';
+import { URLVerbs } from '~/app/shared/constants/app.constants';
 import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { NvmeofEditAuthenticationComponent } from '../nvmeof-edit-authentication/nvmeof-edit-authentication.component';
@@ -27,27 +29,49 @@ export interface SubsystemDetail {
   styleUrls: ['./nvmeof-subsystem-overview.component.scss'],
   standalone: false
 })
-export class NvmeofSubsystemOverviewComponent implements OnInit {
+export class NvmeofSubsystemOverviewComponent implements OnInit, OnDestroy {
   subsystemNQN!: string;
   groupName!: string;
   subsystem!: NvmeofSubsystem;
   details: SubsystemDetail[] = [];
+  private subscriptions = new Subscription();
 
   constructor(
     private route: ActivatedRoute,
+    private router: Router,
     private nvmeofService: NvmeofService,
     private modalService: ModalCdsService
   ) {}
 
   ngOnInit() {
-    this.route.parent?.params.subscribe((params) => {
-      this.subsystemNQN = params['subsystem_nqn'];
-      this.fetchIfReady();
-    });
-    this.route.queryParams.subscribe((qp) => {
-      this.groupName = qp['group'];
-      this.fetchIfReady();
-    });
+    this.subscriptions.add(
+      this.route.parent?.params.subscribe((params) => {
+        this.subsystemNQN = params['subsystem_nqn'];
+        this.fetchIfReady();
+      })
+    );
+    this.subscriptions.add(
+      this.route.queryParams.subscribe((qp) => {
+        this.groupName = qp['group'];
+        this.fetchIfReady();
+      })
+    );
+    this.subscriptions.add(
+      this.router.events
+        .pipe(
+          filter(
+            (event): event is NavigationEnd =>
+              event instanceof NavigationEnd && !event.urlAfterRedirects.includes('(modal:')
+          )
+        )
+        .subscribe(() => {
+          this.fetchIfReady();
+        })
+    );
+  }
+
+  ngOnDestroy() {
+    this.subscriptions.unsubscribe();
   }
 
   private fetchIfReady() {
@@ -179,4 +203,11 @@ export class NvmeofSubsystemOverviewComponent implements OnInit {
       modalRef.closeChange.subscribe(() => this.fetchSubsystem());
     }
   }
+
+  openEditHostAccessModal() {
+    this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'initiator'] } }], {
+      queryParams: { group: this.groupName },
+      relativeTo: this.route.parent
+    });
+  }
 }