]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: NVMe – Fix host,listeners namespace list display on Subsystem resource...
authorpujaoshahu <pshahu@redhat.com>
Mon, 2 Feb 2026 08:46:20 +0000 (14:16 +0530)
committerpujaoshahu <pshahu@redhat.com>
Mon, 23 Feb 2026 14:47:00 +0000 (20:17 +0530)
Fixes: https://tracker.ceph.com/issues/74697
Signed-off-by: pujaoshahu <pshahu@redhat.com>
 Conflicts:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts

Signed-off-by: pujaoshahu <pshahu@redhat.com>
30 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.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-listeners-list/nvmeof-listeners-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss

index ed0583ef9b463253a8ec6e4740869234b3cd6699..92ed9d76872ccb9cfac7978b24cb1c1e9361295f 100644 (file)
@@ -92,6 +92,9 @@ import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.
 import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
 import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
 import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
+import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component';
+import { NvmeSubsystemViewBreadcrumbResolver } from './nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver';
+import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem-view.component';
 
 @NgModule({
   imports: [
@@ -122,11 +125,10 @@ import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof
     TagModule,
     GridModule,
     LayerModule,
-    LayoutModule,
     ContainedListModule,
     SideNavModule,
-    ThemeModule,
-    LayoutModule
+    LayoutModule,
+    ThemeModule
   ],
   declarations: [
     RbdListComponent,
@@ -161,6 +163,7 @@ import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof
     NvmeofListenersFormComponent,
     NvmeofListenersListComponent,
     NvmeofNamespacesListComponent,
+    NvmeofSubsystemNamespacesListComponent,
     NvmeofNamespacesFormComponent,
     NvmeofInitiatorsListComponent,
     NvmeofInitiatorsFormComponent,
@@ -171,7 +174,8 @@ import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof
     NvmeofSubsystemsStepThreeComponent,
     NvmeGatewayViewComponent,
     NvmeofGatewaySubsystemComponent,
-    NvmeofGatewayNodeAddModalComponent
+    NvmeofGatewayNodeAddModalComponent,
+    NvmeSubsystemViewComponent
   ],
 
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
@@ -388,6 +392,26 @@ const routes: Routes = [
             outlet: 'modal'
           }
         ]
+      },
+      {
+        path: `subsystems/:subsystem_nqn`,
+        component: NvmeSubsystemViewComponent,
+        data: { breadcrumbs: NvmeSubsystemViewBreadcrumbResolver },
+        children: [
+          { path: '', redirectTo: 'namespaces', pathMatch: 'full' },
+          {
+            path: 'hosts',
+            component: NvmeofInitiatorsListComponent
+          },
+          {
+            path: 'namespaces',
+            component: NvmeofSubsystemNamespacesListComponent
+          },
+          {
+            path: 'listeners',
+            component: NvmeofListenersListComponent
+          }
+        ]
       }
     ]
   }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.spec.ts
new file mode 100644 (file)
index 0000000..6cf22aa
--- /dev/null
@@ -0,0 +1,16 @@
+import { TestBed } from '@angular/core/testing';
+
+import { NvmeSubsystemViewBreadcrumbResolver } from './nvme-subsystem-view-breadcrumb.resolver';
+
+describe('NvmeSubsystemViewBreadcrumbResolver', () => {
+  let resolver: NvmeSubsystemViewBreadcrumbResolver;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({});
+    resolver = TestBed.inject(NvmeSubsystemViewBreadcrumbResolver);
+  });
+
+  it('should be created', () => {
+    expect(resolver).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts
new file mode 100644 (file)
index 0000000..804a200
--- /dev/null
@@ -0,0 +1,14 @@
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot } from '@angular/router';
+
+import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs';
+
+@Injectable({
+  providedIn: 'root'
+})
+export class NvmeSubsystemViewBreadcrumbResolver extends BreadcrumbsResolver {
+  resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
+    const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn;
+    return [{ text: decodeURIComponent(subsystemNQN || ''), path: null }];
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.html
new file mode 100644 (file)
index 0000000..7b63bee
--- /dev/null
@@ -0,0 +1,4 @@
+<cd-sidebar-layout
+  [title]="subsystemNQN"
+  [items]="sidebarItems"
+></cd-sidebar-layout>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts
new file mode 100644 (file)
index 0000000..254b688
--- /dev/null
@@ -0,0 +1,31 @@
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+
+import { NvmeSubsystemViewComponent } from './nvme-subsystem-view.component';
+
+describe('NvmeSubsystemViewComponent', () => {
+  let component: NvmeSubsystemViewComponent;
+  let fixture: ComponentFixture<NvmeSubsystemViewComponent>;
+
+  beforeEach(
+    waitForAsync(() => {
+      TestBed.configureTestingModule({
+        declarations: [NvmeSubsystemViewComponent],
+        imports: [RouterTestingModule, SideNavModule, ThemeModule],
+        schemas: [CUSTOM_ELEMENTS_SCHEMA]
+      }).compileComponents();
+    })
+  );
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeSubsystemViewComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.ts
new file mode 100644 (file)
index 0000000..5941593
--- /dev/null
@@ -0,0 +1,50 @@
+import { Component, OnInit, ViewEncapsulation } from '@angular/core';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component';
+
+@Component({
+  selector: 'cd-nvme-subsystem-view',
+  templateUrl: './nvme-subsystem-view.component.html',
+  styleUrls: ['./nvme-subsystem-view.component.scss'],
+  encapsulation: ViewEncapsulation.None,
+  standalone: false
+})
+export class NvmeSubsystemViewComponent implements OnInit {
+  subsystemNQN: string;
+  groupName: string;
+  public readonly basePath = '/block/nvmeof/subsystems';
+  sidebarItems: SidebarItem[] = [];
+
+  constructor(private route: ActivatedRoute) {}
+
+  ngOnInit() {
+    this.route.paramMap.subscribe((pm: ParamMap) => {
+      this.subsystemNQN = pm.get('subsystem_nqn') ?? '';
+    });
+    this.route.queryParams.subscribe((qp) => {
+      this.groupName = qp['group'] ?? '';
+      this.buildSidebarItems();
+    });
+  }
+
+  private buildSidebarItems() {
+    const extras = { queryParams: { group: this.groupName } };
+    this.sidebarItems = [
+      {
+        label: $localize`Initiators`,
+        route: [this.basePath, this.subsystemNQN, 'hosts'],
+        routeExtras: extras
+      },
+      {
+        label: $localize`Namespaces`,
+        route: [this.basePath, this.subsystemNQN, 'namespaces'],
+        routeExtras: extras
+      },
+      {
+        label: $localize`Listeners`,
+        route: [this.basePath, this.subsystemNQN, 'listeners'],
+        routeExtras: extras
+      }
+    ];
+  }
+}
index e7f1c2f71eb01c55793737c2a4cf9afb8b3ff22a..4b4ac02794f19d3fa405453a07784218463cebde 100644 (file)
@@ -146,11 +146,6 @@ export class NvmeofGatewayGroupComponent implements OnInit {
                 const subsystemsObservable = isRunning
                   ? this.nvmeofService.listSubsystems(group.spec.group).pipe(
                       catchError(() => {
-                        this.notificationService.show(
-                          NotificationType.error,
-                          $localize`Unable to fetch Gateway group`,
-                          $localize`Gateway group does not exist`
-                        );
                         return of([]);
                       })
                     )
@@ -173,11 +168,6 @@ export class NvmeofGatewayGroupComponent implements OnInit {
             );
           }),
           catchError(() => {
-            this.notificationService.show(
-              NotificationType.error,
-              $localize`Unable to fetch Gateway group`,
-              $localize`Gateway group does not exist`
-            );
             return of([]);
           })
         )
@@ -185,7 +175,6 @@ export class NvmeofGatewayGroupComponent implements OnInit {
     );
     this.checkNodesAvailability();
   }
-
   fetchData(): void {
     this.subject.next([]);
     this.checkNodesAvailability();
index 37a344907f30159ac24b2a827bf993935b89b6d6..523c750caf16b2d4da2de9051f39eb6e403f5b0f 100644 (file)
     heading="Gateway groups"
     [tabContent]="gateways_content"
     i18n-heading
+    [active]="activeTab === Tabs.gateways"
     (selected)="onSelected(Tabs.gateways)">
   </cds-tab>
   <cds-tab
     heading="Subsystem"
     [tabContent]="subsystem_content"
     i18n-heading
+    [active]="activeTab === Tabs.subsystem"
     (selected)="onSelected(Tabs.subsystem)">
   </cds-tab>
   <cds-tab
     heading="Namespace"
     [tabContent]="namespace_content"
     i18n-heading
+    [active]="activeTab === Tabs.namespace"
     (selected)="onSelected(Tabs.namespace)">
   </cds-tab>
 </cds-tabs>
index 893a3aefc2bafd56fa2f62f94ed41f65648fbb66..d1c9b9eeaf4121455c3f2a1076fa8ccbd82dc946 100644 (file)
@@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { NvmeofGatewayComponent } from './nvmeof-gateway.component';
 
 import { HttpClientModule } from '@angular/common/http';
+import { RouterTestingModule } from '@angular/router/testing';
 import { SharedModule } from '~/app/shared/shared.module';
 import { ComboBoxModule, GridModule, TabsModule } from 'carbon-components-angular';
 
@@ -13,7 +14,14 @@ describe('NvmeofGatewayComponent', () => {
   beforeEach(async () => {
     await TestBed.configureTestingModule({
       declarations: [NvmeofGatewayComponent],
-      imports: [HttpClientModule, SharedModule, ComboBoxModule, GridModule, TabsModule],
+      imports: [
+        HttpClientModule,
+        RouterTestingModule,
+        SharedModule,
+        ComboBoxModule,
+        GridModule,
+        TabsModule
+      ],
       providers: []
     }).compileComponents();
 
index a0954e106aa803ee26f1e005ed43443386c2e5ed..7f2eebf1d123a3bb5e593c30b889a9bcfbcbfa8b 100644 (file)
@@ -1,4 +1,5 @@
-import { Component, TemplateRef, ViewChild } from '@angular/core';
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
 
 import _ from 'lodash';
 
@@ -6,9 +7,9 @@ import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 
 enum TABS {
-  'gateways',
-  'subsystem',
-  'namespace'
+  gateways = 'gateways',
+  subsystem = 'subsystem',
+  namespace = 'namespace'
 }
 
 @Component({
@@ -17,20 +18,30 @@ enum TABS {
   styleUrls: ['./nvmeof-gateway.component.scss'],
   standalone: false
 })
-export class NvmeofGatewayComponent {
+export class NvmeofGatewayComponent implements OnInit {
   selectedTab: TABS;
+  activeTab: TABS = TABS.gateways;
+
+  @ViewChild('statusTpl', { static: true })
+  statusTpl: TemplateRef<any>;
+  selection = new CdTableSelection();
+
+  constructor(public actionLabels: ActionLabelsI18n, private route: ActivatedRoute) {}
+
+  ngOnInit() {
+    this.route.queryParams.subscribe((params) => {
+      if (params['tab'] && Object.values(TABS).includes(params['tab'])) {
+        this.activeTab = params['tab'] as TABS;
+      }
+    });
+  }
 
   onSelected(tab: TABS) {
     this.selectedTab = tab;
+    this.activeTab = tab;
   }
 
   public get Tabs(): typeof TABS {
     return TABS;
   }
-
-  @ViewChild('statusTpl', { static: true })
-  statusTpl: TemplateRef<any>;
-  selection = new CdTableSelection();
-
-  constructor(public actionLabels: ActionLabelsI18n) {}
 }
index b0316782099a852d77b0d21b7a26566fda42d333..6f6b8f8896f4725ce969c53c6ae7f2af8bfd1f1e 100644 (file)
@@ -5,6 +5,7 @@ import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { Icons } from '~/app/shared/enum/icons.enum';
 import { Permission } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
@@ -12,8 +13,6 @@ 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',
index cb0139f7df28ea01e6376916a3e0c5122f137d61..e565c1ddb93e1987b0435226c01ddd0e2de8ab4e 100644 (file)
@@ -1,10 +1,29 @@
-<legend>
-  <cd-help-text>
-    An initiator (or host) is the client that connects to the NVMe-oF target to access NVMe storage.
-    The NVMe/TCP protocol allows initiators, to send NVMe-oF commands to storage devices, which are known as targets.
-  </cd-help-text>
-</legend>
-<cd-table [data]="initiators"
+<cd-alert-panel
+  *ngIf="hasAllHostsAllowed()"
+  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>
+    <span
+      class="text-nowrap cds-ml-3 text-muted"
+      i18n
+    >
+      Edit host access
+    </span>
+  </div>
+</cd-alert-panel>
+
+<cd-table [data]="hasAllHostsAllowed() ? [] : initiators"
           columnMode="flex"
           (fetchData)="listInitiators()"
           [columns]="initiatorColumns"
     </cd-table-actions>
   </div>
 </cd-table>
-<ng-template #hostTpl
-             let-value="data.value">
-  <span *ngIf="value === '*'"
-        i18n
-        class="font-monospace">Any host allowed (*)</span>
-  <span *ngIf="value !== '*'"
-        class="font-monospace">{{value}}</span>
+<ng-template #dhchapTpl>
+  <div cdsStack="horizontal"
+       gap="4">
+    {{ authStatus !== authType.NO_AUTH ? 'Yes' : 'No' }}
+  </div>
 </ng-template>
index f8d9c67363251930731d7411060257c095b1063c..5f51162e21908cb58d50f8d506eca300e902dbbe 100644 (file)
@@ -14,19 +14,29 @@ import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list.componen
 
 const mockInitiators = [
   {
-    nqn: '*'
+    nqn: '*',
+    dhchap_key: ''
   }
 ];
 
+const mockSubsystem = {
+  nqn: 'nqn.2016-06.io.spdk:cnode1',
+  serial_number: '12345',
+  psk: ''
+};
+
 class MockNvmeOfService {
   getInitiators() {
     return of(mockInitiators);
   }
+  getSubsystem() {
+    return of(mockSubsystem);
+  }
 }
 
 class MockAuthStorageService {
   getPermissions() {
-    return { nvmeof: {} };
+    return { nvmeof: { read: true, create: true, delete: true } };
   }
 }
 
@@ -52,6 +62,8 @@ describe('NvmeofInitiatorsListComponent', () => {
 
     fixture = TestBed.createComponent(NvmeofInitiatorsListComponent);
     component = fixture.componentInstance;
+    component.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1';
+    component.group = 'group1';
     component.ngOnInit();
     fixture.detectChanges();
   });
@@ -60,9 +72,28 @@ describe('NvmeofInitiatorsListComponent', () => {
     expect(component).toBeTruthy();
   });
 
-  it('should retrieve initiators', fakeAsync(() => {
+  it('should retrieve initiators and subsystem', fakeAsync(() => {
     component.listInitiators();
+    component.getSubsystem();
     tick();
     expect(component.initiators).toEqual(mockInitiators);
+    expect(component.subsystem).toEqual(mockSubsystem);
+    expect(component.authStatus).toBe('No authentication');
+  }));
+
+  it('should update authStatus when initiator has dhchap_key', fakeAsync(() => {
+    const initiatorsWithKey = [{ nqn: 'nqn1', dhchap_key: 'key1' }];
+    spyOn(TestBed.inject(NvmeofService), 'getInitiators').and.returnValue(of(initiatorsWithKey));
+    component.listInitiators();
+    tick();
+    expect(component.authStatus).toBe('Unidirectional');
+  }));
+
+  it('should update authStatus when subsystem has psk', fakeAsync(() => {
+    const subsystemWithPsk = { ...mockSubsystem, psk: 'psk1' };
+    spyOn(TestBed.inject(NvmeofService), 'getSubsystem').and.returnValue(of(subsystemWithPsk));
+    component.getSubsystem();
+    tick();
+    expect(component.authStatus).toBe('Bi-directional');
   }));
 });
index 4cccb4e154f5cb8a71be761ac64063d87bad0cd5..4da1696fffead47c9c4bc9cddf133e9d62f1f9b6 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
-import { Router } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
 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';
@@ -7,8 +7,13 @@ 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 { NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof';
+import {
+  NvmeofSubsystem,
+  NvmeofSubsystemInitiator,
+  getSubsystemAuthStatus
+} from '~/app/shared/models/nvmeof';
 import { Permission } from '~/app/shared/models/permissions';
+import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum';
 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';
@@ -27,14 +32,17 @@ export class NvmeofInitiatorsListComponent implements OnInit {
   @Input()
   group: string;
 
-  @ViewChild('hostTpl', { static: true })
-  hostTpl: TemplateRef<any>;
+  @ViewChild('dhchapTpl', { static: true })
+  dhchapTpl: TemplateRef<any>;
 
   initiatorColumns: any;
   tableActions: CdTableAction[];
   selection = new CdTableSelection();
   permission: Permission;
   initiators: NvmeofSubsystemInitiator[] = [];
+  subsystem: NvmeofSubsystem;
+  authStatus: string;
+  authType = NvmeofSubsystemAuthType;
 
   constructor(
     public actionLabels: ActionLabelsI18n,
@@ -42,17 +50,39 @@ export class NvmeofInitiatorsListComponent implements OnInit {
     private nvmeofService: NvmeofService,
     private modalService: ModalCdsService,
     private router: Router,
-    private taskWrapper: TaskWrapperService
+    private taskWrapper: TaskWrapperService,
+    private route: ActivatedRoute
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
   }
 
   ngOnInit() {
+    if (!this.subsystemNQN || !this.group) {
+      this.route.parent?.params.subscribe((params) => {
+        if (params['subsystem_nqn']) {
+          this.subsystemNQN = params['subsystem_nqn'];
+        }
+        this.fetchIfReady();
+      });
+      this.route.queryParams.subscribe((qp) => {
+        if (qp['group']) {
+          this.group = qp['group'];
+        }
+        this.fetchIfReady();
+      });
+    } else {
+      this.getSubsystem();
+    }
+
     this.initiatorColumns = [
       {
-        name: $localize`Initiator`,
-        prop: 'nqn',
-        cellTemplate: this.hostTpl
+        name: $localize`Host NQN`,
+        prop: 'nqn'
+      },
+      {
+        name: $localize`DHCHAP key`,
+        prop: 'dhchap_key',
+        cellTemplate: this.dhchapTpl
       }
     ];
     this.tableActions = [
@@ -65,7 +95,8 @@ export class NvmeofInitiatorsListComponent implements OnInit {
             [BASE_URL, { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }],
             { queryParams: { group: this.group } }
           ),
-        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+        disable: () => this.hasAllHostsAllowed()
       },
       {
         name: this.actionLabels.REMOVE,
@@ -78,10 +109,28 @@ export class NvmeofInitiatorsListComponent implements OnInit {
     ];
   }
 
+  private fetchIfReady() {
+    if (this.subsystemNQN && this.group) {
+      this.listInitiators();
+      this.getSubsystem();
+    }
+  }
+
   getAllowAllHostIndex() {
     return this.selection.selected.findIndex((selected) => selected.nqn === '*');
   }
 
+  hasAllHostsAllowed(): boolean {
+    return this.initiators.some((initiator) => initiator.nqn === '*');
+  }
+
+  editHostAccess() {
+    this.router.navigate(
+      [BASE_URL, { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }],
+      { queryParams: { group: this.group } }
+    );
+  }
+
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
@@ -91,9 +140,23 @@ export class NvmeofInitiatorsListComponent implements OnInit {
       .getInitiators(this.subsystemNQN, this.group)
       .subscribe((initiators: NvmeofSubsystemInitiator[]) => {
         this.initiators = initiators;
+        this.updateAuthStatus();
       });
   }
 
+  getSubsystem() {
+    this.nvmeofService.getSubsystem(this.subsystemNQN, this.group).subscribe((subsystem: any) => {
+      this.subsystem = subsystem;
+      this.updateAuthStatus();
+    });
+  }
+
+  updateAuthStatus() {
+    if (this.subsystem && this.initiators) {
+      this.authStatus = getSubsystemAuthStatus(this.subsystem, this.initiators);
+    }
+  }
+
   getSelectedNQNs() {
     return this.selection.selected.map((selected) => selected.nqn);
   }
index 9c82832169060c904661662b2d496490a2179d76..2b605c2cce5714d796237f7f1eae69af121f9bc2 100644 (file)
@@ -1,8 +1,11 @@
-<legend>
-  <cd-help-text>
-    A listener defines the IP address and port on the gateway that is used to process NVMe/TCP admin and I/O commands to a subsystem.
-  </cd-help-text>
-</legend>
+<cd-alert-panel *ngIf="listeners && listeners.length === 0"
+                type="error"
+                title="No listeners exists"
+                spacingClass="cds-mb-3"
+                i18n-title>
+  <ng-container i18n>Currently, there are no listeners available in the NVMe subsystem. Please check your configuration or try again later.</ng-container>
+</cd-alert-panel>
+
 <cd-table [data]="listeners"
           columnMode="flex"
           (fetchData)="listListeners()"
index f12a5e75be34c3ea58e34db433c0c813efc30b3f..7276afb5521d43d9b691b4cfdff57fd308699b8b 100644 (file)
@@ -1,5 +1,5 @@
 import { Component, Input, OnInit } from '@angular/core';
-import { Router } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
 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';
@@ -40,15 +40,36 @@ export class NvmeofListenersListComponent implements OnInit {
     private authStorageService: AuthStorageService,
     private taskWrapper: TaskWrapperService,
     private nvmeofService: NvmeofService,
-    private router: Router
+    private router: Router,
+    private route: ActivatedRoute
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
   }
 
   ngOnInit() {
+    // If inputs are not provided, try to get from route params (when used as routed component)
+    if (!this.subsystemNQN || !this.group) {
+      this.route.parent?.params.subscribe((params) => {
+        if (params['subsystem_nqn']) {
+          this.subsystemNQN = params['subsystem_nqn'];
+        }
+        if (this.subsystemNQN && this.group) {
+          this.listListeners();
+        }
+      });
+      this.route.queryParams.subscribe((qp) => {
+        if (qp['group']) {
+          this.group = qp['group'];
+        }
+        if (this.subsystemNQN && this.group) {
+          this.listListeners();
+        }
+      });
+    }
+
     this.listenerColumns = [
       {
-        name: $localize`Host`,
+        name: $localize`Name`,
         prop: 'host_name'
       },
       {
index 0c0c6b424666aa2343fb1d180cd1a0d4826d0995..8c837d3885dd1b0c5698fa1702896a95a8eed69d 100644 (file)
@@ -87,7 +87,8 @@ export class NvmeofNamespacesFormComponent implements OnInit {
       .getNamespace(this.subsystemNQN, this.nsid, this.group)
       .subscribe((res: NvmeofSubsystemNamespace) => {
         const convertedSize = this.dimlessBinaryPipe.transform(res.rbd_image_size).split(' ');
-        this.currentBytes = res.rbd_image_size;
+        this.currentBytes =
+          typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
         this.nsForm.get('pool').setValue(res.rbd_pool_name);
         this.nsForm.get('unit').setValue(convertedSize[1]);
         this.nsForm.get('image_size').setValue(convertedSize[0]);
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.html
new file mode 100644 (file)
index 0000000..8913b85
--- /dev/null
@@ -0,0 +1,19 @@
+  <cd-table [data]="namespaces"
+            columnMode="flex"
+            (fetchData)="listNamespaces()"
+            [columns]="namespacesColumns"
+            selectionType="single"
+            (updateSelection)="updateSelection($event)"
+            emptyStateTitle="No namespaces created."
+            i18n-emptyStateTitle
+            emptyStateMessage="Namespaces are storage volumes mapped to subsystems for host access. Create a namespace to start provisioning storage within a subsystem."
+            i18n-emptyStateMessage>
+
+    <div class="table-actions">
+      <cd-table-actions [permission]="permission"
+                        [selection]="selection"
+                        class="btn-group"
+                        [tableActions]="tableActions">
+      </cd-table-actions>
+    </div>
+  </cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts
new file mode 100644 (file)
index 0000000..a21879a
--- /dev/null
@@ -0,0 +1,91 @@
+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 { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+describe('NvmeofSubsystemNamespacesListComponent', () => {
+  let component: NvmeofSubsystemNamespacesListComponent;
+  let fixture: ComponentFixture<NvmeofSubsystemNamespacesListComponent>;
+  let nvmeofService: NvmeofService;
+
+  const mockNamespaces = [
+    {
+      nsid: 1,
+      subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1',
+      rbd_image_name: 'image1',
+      rbd_pool_name: 'pool1',
+      rbd_image_size: 1024,
+      block_size: 512,
+      rw_ios_per_second: 100
+    },
+    {
+      nsid: 2,
+      subsystem_nqn: 'nqn.2016-06.io.spdk:cnode2', // Different subsystem
+      rbd_image_name: 'image2',
+      rbd_pool_name: 'pool1',
+      rbd_image_size: 1024,
+      block_size: 512,
+      rw_ios_per_second: 100
+    }
+  ];
+
+  class MockAuthStorageService {
+    getPermissions() {
+      return { nvmeof: {} };
+    }
+  }
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofSubsystemNamespacesListComponent],
+      imports: [HttpClientTestingModule, RouterTestingModule, SharedModule],
+      providers: [
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            parent: {
+              params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1', group: 'group1' })
+            },
+            queryParams: of({ group: 'group1' })
+          }
+        },
+        {
+          provide: NvmeofService,
+          useValue: {
+            listNamespaces: jest.fn().mockReturnValue(of(mockNamespaces))
+          }
+        },
+        { provide: AuthStorageService, useClass: MockAuthStorageService }
+      ]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeofSubsystemNamespacesListComponent);
+    component = fixture.componentInstance;
+    nvmeofService = TestBed.inject(NvmeofService);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).not.toBeNull();
+    expect(component).not.toBeUndefined();
+  });
+
+  it('should list namespaces filtered by subsystem', fakeAsync(() => {
+    component.ngOnInit(); // Trigger ngOnInit
+    tick(); // wait for ngOnInit subscription
+    expect(nvmeofService.listNamespaces).toHaveBeenCalledWith(
+      'group1',
+      'nqn.2016-06.io.spdk:cnode1'
+    );
+    expect(component.namespaces.length).toEqual(2);
+    expect(component.namespaces[0].nsid).toEqual(1);
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts
new file mode 100644 (file)
index 0000000..7f3957f
--- /dev/null
@@ -0,0 +1,187 @@
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+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 { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { IopsPipe } from '~/app/shared/pipes/iops.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 { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { combineLatest, Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+const BASE_URL = 'block/nvmeof/subsystems';
+
+@Component({
+  selector: 'cd-nvmeof-subsystem-namespaces-list',
+  templateUrl: './nvmeof-subsystem-namespaces-list.component.html',
+  styleUrls: ['./nvmeof-subsystem-namespaces-list.component.scss'],
+  standalone: false
+})
+export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy {
+  subsystemNQN: string;
+  group: string;
+  namespacesColumns: any;
+  tableActions: CdTableAction[];
+  selection = new CdTableSelection();
+  permission: Permission;
+  namespaces: NvmeofSubsystemNamespace[] = [];
+
+  private destroy$ = new Subject<void>();
+
+  constructor(
+    // ... constructor stays mostly same
+    public actionLabels: ActionLabelsI18n,
+    private router: Router,
+    private modalService: ModalCdsService,
+    private authStorageService: AuthStorageService,
+    private taskWrapper: TaskWrapperService,
+    private nvmeofService: NvmeofService,
+    private dimlessBinaryPipe: DimlessBinaryPipe,
+    private iopsPipe: IopsPipe,
+    private route: ActivatedRoute
+  ) {
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+  }
+
+  ngOnInit() {
+    combineLatest([this.route.parent?.params, this.route.queryParams])
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(([params, qp]) => {
+        this.subsystemNQN = params['subsystem_nqn'];
+        this.group = qp['group'];
+        if (this.subsystemNQN && this.group) {
+          this.listNamespaces();
+        }
+      });
+
+    this.setupColumns();
+    this.setupTableActions();
+  }
+
+  setupColumns() {
+    this.namespacesColumns = [
+      {
+        name: $localize`Namespace ID`,
+        prop: 'nsid'
+      },
+      {
+        name: $localize`Pool`,
+        prop: 'rbd_pool_name',
+        flexGrow: 2
+      },
+      {
+        name: $localize`Image`,
+        prop: 'rbd_image_name',
+        flexGrow: 3
+      },
+      {
+        name: $localize`Image 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
+      }
+    ];
+  }
+
+  setupTableActions() {
+    this.tableActions = [
+      {
+        name: this.actionLabels.CREATE,
+        permission: 'create',
+        icon: Icons.add,
+        click: () =>
+          this.router.navigate(
+            [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }],
+            { queryParams: { group: this.group } }
+          ),
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+      },
+      {
+        name: this.actionLabels.EDIT,
+        permission: 'update',
+        icon: Icons.edit,
+        click: () =>
+          this.router.navigate(
+            [
+              BASE_URL,
+              {
+                outlets: {
+                  modal: [
+                    URLVerbs.EDIT,
+                    this.subsystemNQN,
+                    'namespace',
+                    this.selection.first().nsid
+                  ]
+                }
+              }
+            ],
+            { queryParams: { group: this.group } }
+          )
+      },
+      {
+        name: this.actionLabels.DELETE,
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.deleteNamespaceModal()
+      }
+    ];
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  listNamespaces() {
+    if (this.group) {
+      this.nvmeofService
+        .listNamespaces(this.group, this.subsystemNQN)
+        .pipe(takeUntil(this.destroy$))
+        .subscribe((res: NvmeofSubsystemNamespace[]) => {
+          this.namespaces = res || [];
+        });
+    } else {
+      this.namespaces = [];
+    }
+  }
+
+  deleteNamespaceModal() {
+    const namespace = this.selection.first();
+    this.modalService.show(DeleteConfirmationModalComponent, {
+      itemDescription: 'Namespace',
+      itemNames: [namespace.nsid],
+      actionDescription: 'delete',
+      submitActionObservable: () =>
+        this.taskWrapper.wrapTaskAroundCall({
+          task: new FinishedTask('nvmeof/namespace/delete', {
+            nqn: this.subsystemNQN,
+            nsid: namespace.nsid
+          }),
+          call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group)
+        })
+    });
+  }
+
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+}
index 65f8efea7939703ea68aca96d804b74fdd503236..ca9f7d7ef1901b1854aff86987c80db903de6a8c 100644 (file)
       <a ngbNavLink
          i18n>Listeners</a>
       <ng-template ngbNavContent>
-        <cd-nvmeof-listeners-list [subsystemNQN]="subsystemNQN"
-                                  [group]="group">
-        </cd-nvmeof-listeners-list>
+      <cd-nvmeof-listeners-list
+        [subsystemNQN]="subsystemNQN"
+        [group]="group">
+      </cd-nvmeof-listeners-list>
       </ng-template>
     </ng-container>
     <ng-container ngbNavItem="namespaces">
       <a ngbNavLink
          i18n>Namespaces</a>
       <ng-template ngbNavContent>
-        <cd-nvmeof-namespaces-list [subsystemNQN]="subsystemNQN"
-                                   [group]="group">
-        </cd-nvmeof-namespaces-list>
+    <cd-nvmeof-namespaces-list
+      [subsystemNQN]="subsystemNQN"
+      [group]="group">
+    </cd-nvmeof-namespaces-list>
       </ng-template>
     </ng-container>
     <ng-container ngbNavItem="initiators">
index 7de6ae958a5e23cf854a5fd2ed30c2a265a83d4b..32097cd044a0ab1777c4b4c65f6bbd6d38e3f735 100644 (file)
@@ -25,8 +25,6 @@ export type SubsystemPayload = {
 
 type StepResult = { step: string; success: boolean; error?: string };
 
-const PAGE_URL = 'block/nvmeof/subsystems';
-
 @Component({
   selector: 'cd-nvmeof-subsystems-form',
   templateUrl: './nvmeof-subsystems-form.component.html',
@@ -55,6 +53,7 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
   title: string = $localize`Create Subsystem`;
   description: string = $localize`Subsytems define how hosts connect to NVMe namespaces and ensure secure access to storage.`;
   isSubmitLoading: boolean = false;
+  private lastCreatedNqn: string;
 
   @ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
 
@@ -76,6 +75,7 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
   }
   onSubmit(payload: SubsystemPayload) {
     this.isSubmitLoading = true;
+    this.lastCreatedNqn = payload.nqn;
     const stepResults: StepResult[] = [];
     const initiatorRequest: InitiatorRequest = {
       host_nqn: payload.hostType === HOST_TYPE.ALL ? '*' : payload.addedHosts.join(','),
@@ -117,7 +117,9 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
             errorMsg
           );
           this.isSubmitLoading = false;
-          this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
+          this.router.navigate(['block/nvmeof/gateways'], {
+            queryParams: { group: this.group, tab: 'subsystem' }
+          });
         }
       });
   }
@@ -161,6 +163,12 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
       : $localize`Subsystem created`;
 
     this.notificationService.show(type, title, sanitizedHtml);
-    this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
+    this.router.navigate(['block/nvmeof/gateways'], {
+      queryParams: {
+        group: this.group,
+        tab: 'subsystem',
+        nqn: stepResults[0]?.success ? this.lastCreatedNqn : null
+      }
+    });
   }
 }
index 737ba752660ee441c9b006c7f6c890e8c4b0b2b9..20c797391b2bea935198a67ab694e078988b6faa 100644 (file)
   </cd-table>
 </ng-container>
 
+<ng-template #customTableItemTemplate
+             let-value="data.value"
+             let-row="data.row">
+  <a cdsLink
+     [routerLink]="['/block/nvmeof/subsystems', value]"
+     [queryParams]="{ group: row.gw_group }"
+     (click)="$event.stopPropagation()">
+    {{ value }}
+  </a>
+</ng-template>
+
 <ng-template #authenticationTpl
              let-row="data.row">
   <div [cdsStack]="'horizontal'"
index 468cf64de52df47374f1952cb0c6e9d5be5711a1..67ec2448363fd7eddfb2fab60a17df27823f63b4 100644 (file)
@@ -1,4 +1,11 @@
-import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import {
+  Component,
+  OnDestroy,
+  OnInit,
+  TemplateRef,
+  ViewChild,
+  ChangeDetectorRef
+} from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
@@ -19,13 +26,12 @@ import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
-import { NotificationService } from '~/app/shared/services/notification.service';
-import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
-import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
+import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
 
 const BASE_URL = 'block/nvmeof/subsystems';
 const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
@@ -46,6 +52,13 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
   @ViewChild('deleteTpl', { static: true })
   deleteTpl: TemplateRef<any>;
 
+  @ViewChild('customTableItemTemplate', { static: true })
+  customTableItemTemplate: TemplateRef<any>;
+
+  @ViewChild('table') table: TableComponent;
+
+  subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = [];
+  pendingNqn: string = null;
   subsystemsColumns: any;
   permissions: Permissions;
   selection = new CdTableSelection();
@@ -70,7 +83,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     private route: ActivatedRoute,
     private modalService: ModalCdsService,
     private taskWrapper: TaskWrapperService,
-    private notificationService: NotificationService
+    private cdRef: ChangeDetectorRef
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
@@ -78,6 +91,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
 
   ngOnInit() {
     this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+      if (params?.['nqn']) this.pendingNqn = params['nqn'];
       if (params?.['group']) this.onGroupSelection({ content: params?.['group'] });
     });
     this.setGatewayGroups();
@@ -85,7 +99,8 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
       {
         name: $localize`Subsystem NQN`,
         prop: 'nqn',
-        flexGrow: 2
+        flexGrow: 2,
+        cellTemplate: this.customTableItemTemplate
       },
       {
         name: $localize`Gateway group`,
@@ -144,16 +159,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
             return forkJoin(subs.map((sub) => this.enrichSubsystemWithInitiators(sub)));
           }),
           catchError((error) => {
-            this.notificationService.show(
-              NotificationType.error,
-              $localize`Unable to fetch Gateway group`,
-              $localize`Gateway group does not exist`
-            );
             this.handleError(error);
             return of([]);
           })
         );
       }),
+      tap((subs) => {
+        this.subsystems = subs;
+        this.expandPendingSubsystem();
+      }),
       takeUntil(this.destroy$)
     );
   }
@@ -220,8 +234,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
   }
 
   updateGroupSelectionState() {
-    if (!this.group && this.gwGroups.length) {
-      this.onGroupSelection(this.gwGroups[0]);
+    if (this.gwGroups.length) {
+      if (!this.group) {
+        this.onGroupSelection(this.gwGroups[0]);
+      } else {
+        this.gwGroups = this.gwGroups.map((g) => ({
+          ...g,
+          selected: g.content === this.group
+        }));
+      }
       this.gwGroupsEmpty = false;
       this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER;
     } else {
@@ -244,6 +265,19 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     this.context?.error?.(error);
   }
 
+  private expandPendingSubsystem() {
+    if (!this.pendingNqn) return;
+    const match = this.subsystems.find((s) => s.nqn === this.pendingNqn);
+    if (match && this.table) {
+      setTimeout(() => {
+        this.table.expanded = match;
+        this.table.toggleExpandRow();
+        this.cdRef.detectChanges();
+      });
+    }
+    this.pendingNqn = null;
+  }
+
   private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) {
     return this.nvmeofService.getInitiators(sub.nqn, this.group).pipe(
       catchError(() => of([])),
index 6824aa2c16bfb4760c18108b0ac6b32bf0a56470..142c5be6db7a31da9f6d7fc51b9469f764ab4a31 100644 (file)
@@ -11,6 +11,7 @@
       @for (item of items; track item.label) {
       <cds-sidenav-item
         [route]="item.route"
+        [routeExtras]="item.routeExtras"
         [useRouter]="true"
         [routerLinkActiveOptions]="item.routerLinkActiveOptions || { exact: false }">
         <span class="cds--type-heading-compact-01">{{ item.label }}</span>
index 971a872df31ad473cd7afce95c7b7e2fb7395e4c..8c4a9600c55a031ce1a8ce8650418ed246a85332 100644 (file)
@@ -3,6 +3,7 @@ import { Component, Input, ViewEncapsulation } from '@angular/core';
 export interface SidebarItem {
   label: string;
   route: string[];
+  routeExtras?: any;
   routerLinkActiveOptions?: { exact: boolean };
 }
 
index 159708f31901f3b30652e4feec606e2bd5634212..b55207934b727256d67f930286b76cf246792a6c 100644 (file)
@@ -53,12 +53,14 @@ export interface NvmeofSubsystemNamespace {
   rbd_image_name: string;
   rbd_pool_name: string;
   load_balancing_group: number;
-  rbd_image_size: number;
+  rbd_image_size: number | string;
   block_size: number;
-  rw_ios_per_second: number;
-  rw_mbytes_per_second: number;
-  r_mbytes_per_second: number;
-  w_mbytes_per_second: number;
+  rw_ios_per_second: number | string;
+  rw_mbytes_per_second: number | string;
+  r_mbytes_per_second: number | string;
+  w_mbytes_per_second: number | string;
+  ns_subsystem_nqn?: string; // Field from JSON
+  subsystem_nqn?: string; // Keep for compatibility if needed, but JSON has ns_subsystem_nqn
 }
 
 export interface NvmeofGatewayGroup extends CephServiceSpec {
index 6f59c43b99d7a87c0e19bbf907bae8f0fcfe1475..f32fabfbcbaaa561658be46a080aca35d8b30c71 100644 (file)
   margin-top: layout.$spacing-03;
 }
 
+.cds-mt-1 {
+  margin-top: layout.$spacing-01;
+}
+
 .cds-mt-5 {
   margin-top: layout.$spacing-05;
 }