]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: NVme-gateway-resource 66906/head
authorSagar Gopale <sagar.gopale@ibm.com>
Tue, 10 Feb 2026 06:21:32 +0000 (11:51 +0530)
committerSagar Gopale <sagar.gopale@ibm.com>
Wed, 11 Feb 2026 14:07:37 +0000 (19:37 +0530)
Fixes: https://tracker.ceph.com/issues/74334
Signed-off-by: Sagar Gopale <sagar.gopale@ibm.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-gateway-view/nvme-gateway-view-breadcrumb.resolver.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html
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-node/nvmeof-gateway-node.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/icon/icon.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/url-builder.service.ts
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss

index e97a60b3209ccd68744d43c92f0c8c50a1565f9b..bc3a68c36be3b6d7dfdc349bdd5d467a67b1d50d 100644 (file)
@@ -69,11 +69,13 @@ import {
   SelectModule,
   UIShellModule,
   TreeviewModule,
+  SideNavModule,
   TabsModule,
   TagModule,
   LayoutModule,
   ContainedListModule,
-  LayerModule
+  LayerModule,
+  ThemeModule
 } from 'carbon-components-angular';
 
 // Icons
@@ -84,6 +86,11 @@ import SubtractFilled from '@carbon/icons/es/subtract--filled/32';
 import Reset from '@carbon/icons/es/reset/32';
 import SubtractAlt from '@carbon/icons/es/subtract--alt/20';
 import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
+import Search from '@carbon/icons/es/search/32';
+import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component';
+import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
+import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
+import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
 
 @NgModule({
   imports: [
@@ -100,8 +107,8 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
     TreeviewModule,
     UIShellModule,
     InputModule,
-    GridModule,
     ButtonModule,
+    GridModule,
     IconModule,
     CheckboxModule,
     RadioModule,
@@ -115,7 +122,9 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
     GridModule,
     LayerModule,
     LayoutModule,
-    ContainedListModule
+    ContainedListModule,
+    SideNavModule,
+    ThemeModule
   ],
   declarations: [
     RbdListComponent,
@@ -157,8 +166,11 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
     NvmeofGroupFormComponent,
     NvmeofSubsystemsStepOneComponent,
     NvmeofSubsystemsStepTwoComponent,
-    NvmeofSubsystemsStepThreeComponent
+    NvmeofSubsystemsStepThreeComponent,
+    NvmeGatewayViewComponent,
+    NvmeofGatewaySubsystemComponent
   ],
+
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
 export class BlockModule {
@@ -170,7 +182,8 @@ export class BlockModule {
       SubtractFilled,
       Reset,
       ProgressBarRound,
-      SubtractAlt
+      SubtractAlt,
+      Search
     ]);
   }
 }
@@ -317,6 +330,25 @@ const routes: Routes = [
         component: NvmeofGroupFormComponent,
         data: { breadcrumbs: `${ActionLabels.CREATE}${URLVerbs.GATEWAY_GROUP}` }
       },
+
+      {
+        path: `gateways/${URLVerbs.VIEW}/:group`,
+        component: NvmeGatewayViewComponent,
+        data: { breadcrumbs: NvmeGatewayViewBreadcrumbResolver }, // Use resolver here
+        children: [
+          { path: '', redirectTo: 'nodes', pathMatch: 'full' },
+          {
+            path: 'nodes',
+            component: NvmeofGatewayNodeComponent,
+            data: { breadcrumbs: $localize`Gateway nodes`, mode: NvmeofGatewayNodeMode.DETAILS }
+          },
+          {
+            path: 'subsystems',
+            component: NvmeofGatewaySubsystemComponent,
+            data: { breadcrumbs: $localize`Subsystems` }
+          }
+        ]
+      },
       {
         path: 'subsystems',
         component: NvmeofSubsystemsComponent,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.spec.ts
new file mode 100644 (file)
index 0000000..cd33e36
--- /dev/null
@@ -0,0 +1,48 @@
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRouteSnapshot } from '@angular/router';
+
+import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view-breadcrumb.resolver';
+
+describe('NvmeGatewayViewBreadcrumbResolver', () => {
+  let resolver: NvmeGatewayViewBreadcrumbResolver;
+  let route: ActivatedRouteSnapshot;
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [NvmeGatewayViewBreadcrumbResolver]
+    });
+    resolver = TestBed.inject(NvmeGatewayViewBreadcrumbResolver);
+    route = new ActivatedRouteSnapshot();
+  });
+
+  it('should be created', () => {
+    expect(resolver).toBeTruthy();
+  });
+
+  it('should resolve breadcrumb with group name from parent params', () => {
+    route.params = {};
+    Object.defineProperty(route, 'parent', {
+      value: { params: { group: 'test-group' } },
+      writable: true
+    });
+
+    spyOn(resolver, 'getFullPath').and.returnValue('full/path/test-group');
+
+    const result = resolver.resolve(route);
+
+    expect(result).toEqual([{ text: 'test-group', path: 'full/path/test-group' }]);
+  });
+
+  it('should resolve breadcrumb with group name from current params', () => {
+    route.params = { group: 'test-group' };
+    Object.defineProperty(route, 'parent', {
+      value: { params: {} },
+      writable: true
+    });
+    spyOn(resolver, 'getFullPath').and.returnValue('full/path/test-group');
+
+    const result = resolver.resolve(route);
+
+    expect(result).toEqual([{ text: 'test-group', path: 'full/path/test-group' }]);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver.ts
new file mode 100644 (file)
index 0000000..4bce58c
--- /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 NvmeGatewayViewBreadcrumbResolver extends BreadcrumbsResolver {
+  resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
+    const group = route.parent?.params?.group || route.params?.group;
+    return [{ text: group, path: this.getFullPath(route) }];
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.html
new file mode 100644 (file)
index 0000000..687ee49
--- /dev/null
@@ -0,0 +1,4 @@
+<cd-sidebar-layout
+  [title]="groupName"
+  [items]="sidebarItems"
+></cd-sidebar-layout>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.scss
new file mode 100644 (file)
index 0000000..9d8edd7
--- /dev/null
@@ -0,0 +1,8 @@
+.breadcrumbs--padding {
+  padding-left: 0 !important;
+}
+
+.cds--breadcrumb {
+  margin-top: 0;
+  padding: var(--cds-spacing-05);
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.spec.ts
new file mode 100644 (file)
index 0000000..5893c72
--- /dev/null
@@ -0,0 +1,31 @@
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+
+import { RouterTestingModule } from '@angular/router/testing';
+import { NvmeGatewayViewComponent } from './nvme-gateway-view.component';
+
+describe('NvmeGatewayViewComponent', () => {
+  let component: NvmeGatewayViewComponent;
+  let fixture: ComponentFixture<NvmeGatewayViewComponent>;
+
+  beforeEach(
+    waitForAsync(() => {
+      TestBed.configureTestingModule({
+        declarations: [NvmeGatewayViewComponent],
+        imports: [RouterTestingModule, SideNavModule, ThemeModule],
+        schemas: [CUSTOM_ELEMENTS_SCHEMA]
+      }).compileComponents();
+    })
+  );
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeGatewayViewComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-gateway-view/nvme-gateway-view.component.ts
new file mode 100644 (file)
index 0000000..f5f13e3
--- /dev/null
@@ -0,0 +1,38 @@
+import { Component, OnInit, ViewEncapsulation } from '@angular/core';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+import { Observable, of } from 'rxjs';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component';
+
+@Component({
+  selector: 'cd-nvme-gateway-view',
+  templateUrl: './nvme-gateway-view.component.html',
+  styleUrls: ['./nvme-gateway-view.component.scss'],
+  encapsulation: ViewEncapsulation.None,
+  standalone: false
+})
+export class NvmeGatewayViewComponent implements OnInit {
+  groupName: string;
+  subsystems$: Observable<NvmeofSubsystem[]> = of([]);
+  public readonly basePath = '/block/nvmeof/gateways/view';
+  sidebarItems: SidebarItem[] = [];
+
+  constructor(private route: ActivatedRoute) {}
+
+  ngOnInit() {
+    this.route.paramMap.subscribe((pm: ParamMap) => {
+      this.groupName = pm.get('group') ?? '';
+      this.sidebarItems = [
+        {
+          label: $localize`Gateway nodes`,
+          route: [this.basePath, this.groupName, 'nodes'],
+          routerLinkActiveOptions: { exact: true }
+        },
+        {
+          label: $localize`Subsystems`,
+          route: [this.basePath, this.groupName, 'subsystems']
+        }
+      ];
+    });
+  }
+}
index 8c629d76836de2e2c1ab4a7bfee4a013545aa92f..c3aaa0d4a4fc0d84cf9e75609604c42b25831a30 100644 (file)
   <span *ngIf="created">{{ created | date:'EEE d MMM, yyyy' }}</span>
 </ng-template>
 
+<ng-template #customTableItemTemplate
+             let-value="data.value">
+  <a cdsLink
+     [routerLink]="[viewUrl, value | encodeUri]"
+     (click)="$event.stopPropagation()">
+    {{ value }}
+  </a>
+</ng-template>
+
 <ng-template #gatewayStatusTpl
              let-gateway="data.value">
   <div [cdsStack]="'horizontal'"
index bcfd910d55e340fd14a7e3457a71dbff7f98d19e..e7f1c2f71eb01c55793737c2a4cf9afb8b3ff22a 100644 (file)
@@ -1,6 +1,7 @@
 import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
+import { Router } from '@angular/router';
 import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
-import { catchError, map, switchMap, tap } from 'rxjs/operators';
+import { catchError, map, switchMap } from 'rxjs/operators';
 import { GatewayGroup, NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { HostService } from '~/app/shared/api/host.service';
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
@@ -41,12 +42,15 @@ export class NvmeofGatewayGroupComponent implements OnInit {
   @ViewChild('dateTpl', { static: true })
   dateTpl: TemplateRef<any>;
 
-  @ViewChild('gatewayStatusTpl', { static: true })
-  gatewayStatusTpl: TemplateRef<any>;
+  @ViewChild('customTableItemTemplate', { static: true })
+  customTableItemTemplate: TemplateRef<any>;
 
   @ViewChild('deleteTpl', { static: true })
   deleteTpl: TemplateRef<any>;
 
+  @ViewChild('gatewayStatusTpl', { static: true })
+  gatewayStatusTpl: TemplateRef<any>;
+
   permission: Permission;
   tableActions: CdTableAction[];
   nodesAvailable = false;
@@ -59,6 +63,7 @@ export class NvmeofGatewayGroupComponent implements OnInit {
   subsystemCount: number;
   gatewayCount: number;
 
+  viewUrl = `/${BASE_URL}/view`;
   icons = Icons;
 
   iconSize = IconSize;
@@ -72,7 +77,8 @@ export class NvmeofGatewayGroupComponent implements OnInit {
     private cephServiceService: CephServiceService,
     public taskWrapper: TaskWrapperService,
     private notificationService: NotificationService,
-    private urlBuilder: URLBuilderService
+    private urlBuilder: URLBuilderService,
+    private router: Router
   ) {}
 
   ngOnInit(): void {
@@ -81,7 +87,8 @@ export class NvmeofGatewayGroupComponent implements OnInit {
     this.columns = [
       {
         name: $localize`Name`,
-        prop: 'name'
+        prop: 'name',
+        cellTemplate: this.customTableItemTemplate
       },
       {
         name: $localize`Gateways`,
@@ -107,6 +114,14 @@ export class NvmeofGatewayGroupComponent implements OnInit {
       canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
     };
 
+    const viewAction: CdTableAction = {
+      permission: 'read',
+      icon: Icons.eye,
+      click: () => this.getViewDetails(),
+      name: $localize`View details`,
+      canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+    };
+
     const deleteAction: CdTableAction = {
       permission: 'delete',
       icon: Icons.destroy,
@@ -114,7 +129,9 @@ export class NvmeofGatewayGroupComponent implements OnInit {
       name: this.actionLabels.DELETE,
       canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
     };
-    this.tableActions = [createAction, deleteAction];
+
+    this.tableActions = [createAction, viewAction, deleteAction];
+
     this.gatewayGroup$ = this.subject.pipe(
       switchMap(() =>
         this.nvmeofService.listGatewayGroups().pipe(
@@ -207,7 +224,7 @@ export class NvmeofGatewayGroupComponent implements OnInit {
             call: this.cephServiceService.delete(serviceName)
           })
           .pipe(
-            tap(() => {
+            map(() => {
               this.table.refreshBtn();
             }),
             catchError((error) => {
@@ -222,7 +239,6 @@ export class NvmeofGatewayGroupComponent implements OnInit {
       }
     });
   }
-
   private checkNodesAvailability(): void {
     forkJoin([this.nvmeofService.listGatewayGroups(), this.hostService.getAllHosts()]).subscribe(
       ([groups, hosts]: [GatewayGroup[][], any[]]) => {
@@ -245,4 +261,16 @@ export class NvmeofGatewayGroupComponent implements OnInit {
       }
     );
   }
+
+  getViewDetails() {
+    const selectedGroup = this.selection.first();
+    if (!selectedGroup) {
+      return;
+    }
+    const groupName = selectedGroup.name;
+    if (!groupName) {
+      return;
+    }
+    this.router.navigate([this.viewUrl, groupName]);
+  }
 }
index 7dc017449018f65b09e4cd64ec62f40275543886..bdbde0054067486a20a718bea43d99d74906a5ef 100644 (file)
@@ -1,21 +1,26 @@
-
-<cd-table
-  #table
-  [data]="hosts"
-  [columns]="columns"
-  columnMode="flex"
-  (fetchData)="getHosts($event)"
-  selectionType="multiClick"
-  [searchableObjects]="true"
-  [serverSide]="false"
-  [maxLimit]="25"
-  (updateSelection)="updateSelection($event)"
-  emptyStateTitle="No nodes available"
-  i18n-emptyStateTitle
-  emptyStateMessage="Add your first gateway node to start using NVMe over Fabrics. Nodes provide the resources required to expose NVMe/TCP block storage."
-  i18n-emptyStateMessage
->
-</cd-table>
+<div>
+  <cd-table
+    #table
+    [data]="hosts"
+    [columns]="columns"
+    columnMode="flex"
+    (fetchData)="getHosts($event)"
+    [selectionType]="selectionType"
+    [searchableObjects]="true"
+    [serverSide]="false"
+    [maxLimit]="25"
+    identifier="hostname"
+    forceIdentifier="true"
+    (updateSelection)="updateSelection($event)"
+  >
+    <cd-table-actions
+      class="table-actions"
+      [permission]="permission"
+      [selection]="selection"
+      [tableActions]="tableActions">
+    </cd-table-actions>
+  </cd-table>
+</div>
 
 <ng-template
   #addrTpl
@@ -30,6 +35,7 @@
   let-value="data.value"
   let-row="data.row"
 >
+@if (value) {
   <div
     [cdsStack]="'horizontal'"
     gap="4"
   @if (value === HostStatus.AVAILABLE) {
     <cd-icon type="success"></cd-icon>
   }
-
   <span class="cds-ml-3">{{ value | titlecase }}</span>
   </div>
-
+} @else {
+  <span>-</span>
+}
 </ng-template>
 
 <ng-template #labelsTpl
index da5ad197af65b242f082ed9948e868ea212189e1..576b2e19ad654dd6091e58281f0a5038f648a96d 100644 (file)
@@ -1,10 +1,11 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+import { BehaviorSubject, of, throwError } from 'rxjs';
+
 import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
 import { RouterTestingModule } from '@angular/router/testing';
 
-import { of, throwError } from 'rxjs';
-
 import { CephModule } from '~/app/ceph/ceph.module';
 import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module';
 import { CoreModule } from '~/app/core/core.module';
@@ -97,7 +98,21 @@ describe('NvmeofGatewayNodeComponent', () => {
       CoreModule,
       TagModule
     ],
-    providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+    providers: [
+      { provide: AuthStorageService, useValue: fakeAuthStorageService },
+      {
+        provide: ActivatedRoute,
+        useValue: {
+          parent: {
+            params: new BehaviorSubject({ group: 'group1' })
+          },
+          data: of({ mode: 'selector' }),
+          snapshot: {
+            data: { mode: 'selector' }
+          }
+        }
+      }
+    ]
   });
 
   beforeEach(() => {
@@ -134,7 +149,7 @@ describe('NvmeofGatewayNodeComponent', () => {
   it('should initialize with default values', () => {
     expect(component.hosts).toEqual([]);
     expect(component.isLoadingHosts).toBe(false);
-    expect(component.count).toBe(5);
+    expect(component.totalHostCount).toBe(5);
     expect(component.permission).toBeDefined();
   });
 
@@ -152,16 +167,11 @@ describe('NvmeofGatewayNodeComponent', () => {
     component.selection = new CdTableSelection();
     component.selection.selected = [mockGatewayNodes[0], mockGatewayNodes[1]];
 
-    // ensure hosts list contains the selected hosts for lookup
-    component.hosts = [mockGatewayNodes[0], mockGatewayNodes[1]];
-
-    const selectedHosts = component
-      .getSelectedHostnames()
-      .map((hostname) => component.hosts.find((host) => host.hostname === hostname));
+    const selectedHosts = component.getSelectedHostnames();
 
     expect(selectedHosts.length).toBe(2);
-    expect(selectedHosts[0]).toEqual(mockGatewayNodes[0]);
-    expect(selectedHosts[1]).toEqual(mockGatewayNodes[1]);
+    expect(selectedHosts[0]).toEqual(mockGatewayNodes[0].hostname);
+    expect(selectedHosts[1]).toEqual(mockGatewayNodes[1].hostname);
   });
 
   it('should get selected hostnames', () => {
@@ -173,7 +183,7 @@ describe('NvmeofGatewayNodeComponent', () => {
     expect(selectedHostnames).toEqual(['gateway-node-1', 'gateway-node-2']);
   });
 
-  it('should load hosts with orchestrator available and facts feature enabled', (done) => {
+  it('should load hosts with orchestrator available and facts feature enabled', fakeAsync(() => {
     const hostListSpy = spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
     const mockOrcStatus: any = {
       available: true,
@@ -181,46 +191,32 @@ describe('NvmeofGatewayNodeComponent', () => {
     };
 
     spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
-    spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
-    fixture.detectChanges();
-
-    component.getHosts(new CdTableFetchDataContext(() => undefined));
-
-    setTimeout(() => {
-      expect(hostListSpy).toHaveBeenCalled();
-      // Only hosts with status 'available', '' or 'running' are included (excluding 'maintenance')
-      expect(component.hosts.length).toBe(2);
-      expect(component.isLoadingHosts).toBe(false);
-      expect(component.hosts[0]['hostname']).toBe('gateway-node-1');
-      expect(component.hosts[0]['status']).toBe(HostStatus.AVAILABLE);
-      done();
-    }, 100);
-  });
-
-  it('should normalize empty status to "available"', (done) => {
-    spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
-    const mockOrcStatus: any = {
-      available: true,
-      features: new Map()
-    };
-
-    spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+      of([
+        [
+          {
+            service_id: 'nvmeof.group1',
+            placement: { hosts: ['gateway-node-1'] }
+          }
+        ]
+      ] as any)
+    );
     spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    component.groupName = 'group1';
     fixture.detectChanges();
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
-    setTimeout(() => {
-      // Host at index 1 in filtered list (gateway-node-3 has empty status which becomes 'available')
-      const nodeWithEmptyStatus = component.hosts.find((h) => h.hostname === 'gateway-node-3');
-      expect(nodeWithEmptyStatus?.['status']).toBe(HostStatus.AVAILABLE);
-      done();
-    }, 100);
-  });
+    tick(100);
+    expect(hostListSpy).toHaveBeenCalled();
+    // Hosts NOT in usedHostnames are included (gateway-node-1 is used, so filtered out)
+    // gateway-node-2 and gateway-node-3 are returned (status is not filtered)
+    expect(component.hosts.length).toBe(2);
+    expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-2');
+    expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-3');
+  }));
 
-  it('should set count to hosts length', (done) => {
+  it('should set count to hosts length', fakeAsync(() => {
     spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
     const mockOrcStatus: any = {
       available: true,
@@ -228,20 +224,28 @@ describe('NvmeofGatewayNodeComponent', () => {
     };
 
     spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+      of([
+        [
+          {
+            service_id: 'nvmeof.group1',
+            placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
+          }
+        ]
+      ] as any)
+    );
     spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    component.groupName = 'group1';
     fixture.detectChanges();
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
-    setTimeout(() => {
-      // Count should equal the filtered hosts length
-      expect(component.count).toBe(component.hosts.length);
-      done();
-    }, 100);
-  });
+    tick(100);
+    // Count should equal the filtered hosts length
+    expect(component.totalHostCount).toBe(component.hosts.length);
+  }));
 
-  it('should set count to 0 when no hosts are returned', (done) => {
+  it('should set count to 0 when no hosts are returned', fakeAsync(() => {
     spyOn(hostService, 'list').and.returnValue(of([]));
     const mockOrcStatus: any = {
       available: true,
@@ -249,20 +253,28 @@ describe('NvmeofGatewayNodeComponent', () => {
     };
 
     spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+      of([
+        [
+          {
+            service_id: 'nvmeof.group1',
+            placement: { hosts: ['gateway-node-1'] }
+          }
+        ]
+      ] as any)
+    );
     spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    component.groupName = 'group1';
     fixture.detectChanges();
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
-    setTimeout(() => {
-      expect(component.count).toBe(0);
-      expect(component.hosts.length).toBe(0);
-      done();
-    }, 100);
-  });
+    tick(100);
+    expect(component.totalHostCount).toBe(0);
+    expect(component.hosts.length).toBe(0);
+  }));
 
-  it('should handle error when fetching hosts', (done) => {
+  it('should handle error when fetching hosts', fakeAsync(() => {
     const errorMsg = 'Failed to fetch hosts';
     spyOn(hostService, 'list').and.returnValue(throwError(() => new Error(errorMsg)));
     const mockOrcStatus: any = {
@@ -271,8 +283,18 @@ describe('NvmeofGatewayNodeComponent', () => {
     };
 
     spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+      of([
+        [
+          {
+            service_id: 'nvmeof.group1',
+            placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
+          }
+        ]
+      ] as any)
+    );
     spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    component.groupName = 'group1';
     fixture.detectChanges();
 
     const context = new CdTableFetchDataContext(() => undefined);
@@ -280,80 +302,37 @@ describe('NvmeofGatewayNodeComponent', () => {
 
     component.getHosts(context);
 
-    setTimeout(() => {
-      expect(component.isLoadingHosts).toBe(false);
-      expect(context.error).toHaveBeenCalled();
-      done();
-    }, 100);
-  });
-
-  it('should check hosts facts available when orchestrator features present', () => {
-    component.orchStatus = {
-      available: true,
-      features: new Map([['get_facts', { available: true }]])
-    } as any;
-
-    spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
-
-    const result = component.checkHostsFactsAvailable();
-
-    expect(result).toBe(true);
-  });
-
-  it('should return false when get_facts feature is not available', () => {
-    component.orchStatus = {
-      available: true,
-      features: new Map([['other_feature', { available: true }]])
-    } as any;
-
-    const result = component.checkHostsFactsAvailable();
-
-    expect(result).toBe(false);
-  });
-
-  it('should return false when orchestrator status features are empty', () => {
-    component.orchStatus = {
-      available: true,
-      features: new Map()
-    } as any;
-
-    const result = component.checkHostsFactsAvailable();
-
-    expect(result).toBe(false);
-  });
-
-  it('should return false when orchestrator status is undefined', () => {
-    component.orchStatus = undefined;
-
-    const result = component.checkHostsFactsAvailable();
-
-    expect(result).toBe(false);
-  });
+    tick(100);
+    expect(component.isLoadingHosts).toBe(false);
+    expect(context.error).toHaveBeenCalled();
+  }));
 
-  it('should not re-fetch if already loading', (done) => {
+  it('should not re-fetch if already loading', fakeAsync(() => {
     component.isLoadingHosts = true;
     const hostListSpy = spyOn(hostService, 'list');
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
-    setTimeout(() => {
-      expect(hostListSpy).not.toHaveBeenCalled();
-      done();
-    }, 100);
-  });
+    tick(100);
+    expect(hostListSpy).not.toHaveBeenCalled();
+  }));
 
-  it('should unsubscribe on component destroy', () => {
-    const destroy$ = component['destroy$'];
-    spyOn(destroy$, 'next');
-    spyOn(destroy$, 'complete');
+  it('should unsubscribe on component destroy', fakeAsync(() => {
+    spyOn(hostService, 'list').and.returnValue(of([]));
+    spyOn(orchService, 'status').and.returnValue(of({} as any));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    component.getHosts(new CdTableFetchDataContext(() => undefined));
+    tick(100);
+
+    const sub = component['sub'];
+    spyOn(sub, 'unsubscribe');
 
     component.ngOnDestroy();
 
-    expect(destroy$.next).toHaveBeenCalled();
-    expect(destroy$.complete).toHaveBeenCalled();
-  });
+    expect(sub.unsubscribe).toHaveBeenCalled();
+  }));
 
-  it('should handle host list with various label types', (done) => {
+  it('should handle host list with various label types', fakeAsync(() => {
     const hostsWithLabels = [
       {
         ...mockGatewayNodes[0],
@@ -372,20 +351,28 @@ describe('NvmeofGatewayNodeComponent', () => {
     };
 
     spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+      of([
+        [
+          {
+            service_id: 'nvmeof.group1',
+            placement: { hosts: ['gateway-node-2'] }
+          }
+        ]
+      ] as any)
+    );
     spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    component.groupName = 'group1';
     fixture.detectChanges();
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
-    setTimeout(() => {
-      expect(component.hosts[0]['labels'].length).toBe(3);
-      expect(component.hosts[1]['labels'].length).toBe(0);
-      done();
-    }, 100);
-  });
+    tick(100);
+    expect(component.hosts[0]['labels'].length).toBe(3);
+    expect(component.hosts[1]['labels'].length).toBe(0);
+  }));
 
-  it('should handle hosts with multiple services', (done) => {
+  it('should handle hosts with multiple services', fakeAsync(() => {
     const hostsWithServices = [
       {
         ...mockGatewayNodes[0],
@@ -403,19 +390,27 @@ describe('NvmeofGatewayNodeComponent', () => {
     };
 
     spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+      of([
+        [
+          {
+            service_id: 'nvmeof.group1',
+            placement: { hosts: ['gateway-node-2'] }
+          }
+        ]
+      ] as any)
+    );
     spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    component.groupName = 'group1';
     fixture.detectChanges();
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
-    setTimeout(() => {
-      expect(component.hosts[0]['services'].length).toBe(2);
-      done();
-    }, 100);
-  });
+    tick(100);
+    expect(component.hosts[0]['services'].length).toBe(2);
+  }));
 
-  it('should initialize table context on first getHosts call', (done) => {
+  it('should initialize table context on first getHosts call', fakeAsync(() => {
     spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
     const mockOrcStatus: any = {
       available: true,
@@ -423,21 +418,29 @@ describe('NvmeofGatewayNodeComponent', () => {
     };
 
     spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+      of([
+        [
+          {
+            service_id: 'nvmeof.group1',
+            placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
+          }
+        ]
+      ] as any)
+    );
     spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    component.groupName = 'group1';
     fixture.detectChanges();
 
-    expect((component as any).tableContext).toBeNull();
+    expect((component as any).tableContext).toBeUndefined();
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
-    setTimeout(() => {
-      expect((component as any).tableContext).not.toBeNull();
-      done();
-    }, 100);
-  });
+    tick(100);
+    expect((component as any).tableContext).toBeDefined();
+  }));
 
-  it('should reuse table context if already set', (done) => {
+  it('should reuse table context if already set', fakeAsync(() => {
     const context = new CdTableFetchDataContext(() => undefined);
     spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
     const mockOrcStatus: any = {
@@ -452,10 +455,50 @@ describe('NvmeofGatewayNodeComponent', () => {
 
     component.getHosts(context);
 
-    setTimeout(() => {
-      const storedContext = (component as any).tableContext;
-      expect(storedContext).toBe(context);
-      done();
-    }, 100);
+    tick(100);
+    const storedContext = (component as any).tableContext;
+    expect(storedContext).toBe(context);
+  }));
+
+  it('should fetch data using fetchHostsAndGroups in details mode', fakeAsync(() => {
+    (component as any).route.data = of({ mode: 'details' });
+    component.ngOnInit();
+    component.groupName = 'group1';
+
+    spyOn(nvmeofService, 'fetchHostsAndGroups').and.returnValue(
+      of({
+        groups: [
+          [
+            {
+              service_id: 'nvmeof.group1',
+              spec: { group: 'group1' },
+              placement: { hosts: ['gateway-node-1'] }
+            }
+          ]
+        ],
+        hosts: mockGatewayNodes
+      } as any)
+    );
+
+    fixture.detectChanges();
+    component.getHosts(new CdTableFetchDataContext(() => undefined));
+    tick(100);
+
+    expect(nvmeofService.fetchHostsAndGroups).toHaveBeenCalled();
+    expect(component.hosts.length).toBe(1);
+    expect(component.hosts[0].hostname).toBe('gateway-node-1');
+    expect(component.hosts[0].hostname).toBe('gateway-node-1');
+  }));
+
+  it('should set selectionType to multiClick in selector mode', () => {
+    (component as any).route.data = of({ mode: 'selector' });
+    component.ngOnInit();
+    expect(component.selectionType).toBe('multiClick');
+  });
+
+  it('should set selectionType to single in details mode', () => {
+    (component as any).route.data = of({ mode: 'details' });
+    component.ngOnInit();
+    expect(component.selectionType).toBe('single');
   });
 });
index 69d61470c553281af3689eb3ff93e21707d02a0e..ae777677f541313db5e2a052d1496dce2f4e0159 100644 (file)
@@ -1,20 +1,24 @@
 import {
   Component,
   EventEmitter,
+  Input,
   OnDestroy,
   OnInit,
   Output,
   TemplateRef,
   ViewChild
 } from '@angular/core';
-import { forkJoin, Subject } from 'rxjs';
-import { map, mergeMap, takeUntil } from 'rxjs/operators';
+import { ActivatedRoute } from '@angular/router';
+import { forkJoin, Subject, Subscription } from 'rxjs';
+import { finalize, mergeMap } from 'rxjs/operators';
 
 import { HostService } from '~/app/shared/api/host.service';
 import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
 import { TableComponent } from '~/app/shared/datatable/table/table.component';
 import { HostStatus } from '~/app/shared/enum/host-status.enum';
 import { Icons } from '~/app/shared/enum/icons.enum';
+import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
+
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
@@ -27,8 +31,6 @@ import { CephServiceSpec } from '~/app/shared/models/service.interface';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 
-import _ from 'lodash';
-
 @Component({
   selector: 'cd-nvmeof-gateway-node',
   templateUrl: './nvmeof-gateway-node.component.html',
@@ -37,51 +39,85 @@ import _ from 'lodash';
 })
 export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
   @ViewChild(TableComponent, { static: true })
-  table: TableComponent;
+  table!: TableComponent;
 
   @ViewChild('hostNameTpl', { static: true })
-  hostNameTpl: TemplateRef<any>;
+  hostNameTpl!: TemplateRef<any>;
 
   @ViewChild('statusTpl', { static: true })
-  statusTpl: TemplateRef<any>;
+  statusTpl!: TemplateRef<any>;
 
   @ViewChild('addrTpl', { static: true })
-  addrTpl: TemplateRef<any>;
+  addrTpl!: TemplateRef<any>;
 
   @ViewChild('labelsTpl', { static: true })
-  labelsTpl: TemplateRef<any>;
-
-  @ViewChild('orchTmpl', { static: true })
-  orchTmpl: TemplateRef<any>;
+  labelsTpl!: TemplateRef<any>;
 
   @Output() selectionChange = new EventEmitter<CdTableSelection>();
   @Output() hostsLoaded = new EventEmitter<number>();
+  @Input() groupName: string | undefined;
+  @Input() mode: NvmeofGatewayNodeMode = NvmeofGatewayNodeMode.SELECTOR;
 
   usedHostnames: Set<string> = new Set();
+  serviceSpec: CephServiceSpec | undefined;
 
   permission: Permission;
   columns: CdTableColumn[] = [];
   hosts: Host[] = [];
   isLoadingHosts = false;
-  tableActions: CdTableAction[];
+  tableActions: CdTableAction[] = [];
+  selectionType: 'single' | 'multiClick' | 'none' = 'single';
+
   selection = new CdTableSelection();
   icons = Icons;
   HostStatus = HostStatus;
-  private tableContext: CdTableFetchDataContext = null;
-  count = 5;
-  orchStatus: OrchestratorStatus;
+  private tableContext: CdTableFetchDataContext | undefined;
+  totalHostCount = 5;
+  orchStatus: OrchestratorStatus | undefined;
   private destroy$ = new Subject<void>();
+  private sub: Subscription | undefined;
 
   constructor(
     private authStorageService: AuthStorageService,
     private hostService: HostService,
     private orchService: OrchestratorService,
-    private nvmeofService: NvmeofService
+    private nvmeofService: NvmeofService,
+    private route: ActivatedRoute
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
   }
 
   ngOnInit(): void {
+    this.route.data.subscribe((data) => {
+      if (data?.['mode']) {
+        this.mode = data['mode'];
+      }
+    });
+
+    this.selectionType = this.mode === NvmeofGatewayNodeMode.SELECTOR ? 'multiClick' : 'single';
+
+    if (this.mode === NvmeofGatewayNodeMode.DETAILS) {
+      this.route.parent?.params.subscribe((params: { group: string }) => {
+        this.groupName = params.group;
+      });
+      this.tableActions = [
+        {
+          permission: 'create',
+          icon: Icons.add,
+          click: () => this.addGateway(),
+          name: $localize`Add`,
+          canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+        },
+        {
+          permission: 'delete',
+          icon: Icons.destroy,
+          click: () => this.removeGateway(),
+          name: $localize`Remove`,
+          disable: (selection: CdTableSelection) => !selection.hasSelection
+        }
+      ];
+    }
+
     this.columns = [
       {
         name: $localize`Hostname`,
@@ -102,7 +138,7 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
         cellTemplate: this.statusTpl
       },
       {
-        name: $localize`Labels`,
+        name: $localize`Labels (tags)`,
         prop: 'labels',
         flexGrow: 1,
         cellTemplate: this.labelsTpl
@@ -110,9 +146,20 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
     ];
   }
 
+  addGateway(): void {
+    // TODO
+  }
+
+  removeGateway(): void {
+    // TODO
+  }
+
   ngOnDestroy(): void {
     this.destroy$.next();
     this.destroy$.complete();
+    if (this.sub) {
+      this.sub.unsubscribe();
+    }
   }
 
   updateSelection(selection: CdTableSelection): void {
@@ -125,73 +172,96 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
   }
 
   getHosts(context: CdTableFetchDataContext): void {
-    if (context !== null) {
-      this.tableContext = context;
-    }
-    if (this.tableContext == null) {
-      this.tableContext = new CdTableFetchDataContext(() => undefined);
-    }
+    this.tableContext =
+      context || this.tableContext || new CdTableFetchDataContext(() => undefined);
     if (this.isLoadingHosts) {
       return;
     }
     this.isLoadingHosts = true;
 
-    forkJoin([this.buildUsedHostsObservable(), this.buildHostListObservable()])
-      .pipe(takeUntil(this.destroy$))
-      .subscribe(
-        ([usedHostnames, hostList]: [Set<string>, Host[]]) =>
-          this.processHostResults(usedHostnames, hostList),
-        () => {
+    if (this.sub) {
+      this.sub.unsubscribe();
+    }
+
+    const fetchData$ =
+      this.mode === NvmeofGatewayNodeMode.DETAILS
+        ? this.nvmeofService.fetchHostsAndGroups()
+        : forkJoin({
+            groups: this.nvmeofService.listGatewayGroups(),
+            hosts: this.orchService.status().pipe(
+              mergeMap((orchStatus: OrchestratorStatus) => {
+                this.orchStatus = orchStatus;
+                const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus);
+                return this.hostService.list(
+                  this.tableContext?.toParams(),
+                  factsAvailable.toString()
+                );
+              })
+            )
+          });
+
+    this.sub = fetchData$
+      .pipe(
+        finalize(() => {
           this.isLoadingHosts = false;
-          context.error();
-        }
-      );
+        })
+      )
+      .subscribe({
+        next: (result: any) => {
+          this.mode === NvmeofGatewayNodeMode.DETAILS
+            ? this.processHostsForDetailsMode(result.groups, result.hosts)
+            : this.processHostsForSelectorMode(result.groups, result.hosts);
+        },
+        error: () => context?.error()
+      });
   }
 
-  private buildUsedHostsObservable() {
-    return this.nvmeofService.listGatewayGroups().pipe(
-      map((groups: CephServiceSpec[][]) => {
-        const usedHosts = new Set<string>();
-        const groupList = groups?.[0] ?? [];
-        groupList.forEach((group: CephServiceSpec) => {
-          const hosts = group.placement?.hosts || [];
-          hosts.forEach((hostname: string) => usedHosts.add(hostname));
-        });
-        return usedHosts;
-      })
-    );
+  /**
+   * Selector Mode: Used in 'Add/Create' forms.
+   * Filters the entire cluster inventory to show only **available** candidates
+   * (excluding nodes that are already part of a gateway group).
+   */
+  private processHostsForSelectorMode(groups: CephServiceSpec[][] = [[]], hostList: Host[] = []) {
+    const usedHosts = new Set<string>();
+    (groups?.[0] ?? []).forEach((group: CephServiceSpec) => {
+      group.placement?.hosts?.forEach((hostname: string) => usedHosts.add(hostname));
+    });
+    this.usedHostnames = usedHosts;
+
+    this.hosts = (hostList || []).filter((host: Host) => !this.usedHostnames.has(host.hostname));
+
+    this.updateCount();
   }
 
-  private buildHostListObservable() {
-    return this.orchService.status().pipe(
-      mergeMap((orchStatus) => {
-        this.orchStatus = orchStatus;
-        const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus);
-        return this.hostService.list(this.tableContext?.toParams(), factsAvailable.toString());
-      })
+  /**
+   * Details Mode: Used in 'Details' views.
+   * Filters specifically for the nodes that are **configured members**
+   * of the current gateway group, regardless of their status.
+   */
+  private processHostsForDetailsMode(groups: any[][], hostList: Host[]) {
+    const groupList = groups?.[0] ?? [];
+    const currentGroup: CephServiceSpec | undefined = groupList.find(
+      (group: CephServiceSpec) => group.spec?.group === this.groupName
     );
-  }
 
-  private processHostResults(usedHostnames: Set<string>, hostList: Host[]) {
-    this.usedHostnames = usedHostnames;
-    this.hosts = (hostList || [])
-      .map((host: Host) => ({
-        ...host,
-        status: host.status || HostStatus.AVAILABLE
-      }))
-      .filter((host: Host) => {
-        const isNotUsed = !this.usedHostnames.has(host.hostname);
-        const status = host.status || HostStatus.AVAILABLE;
-        const isAvailable = status === HostStatus.AVAILABLE || status === HostStatus.RUNNING;
-        return isNotUsed && isAvailable;
+    if (!currentGroup) {
+      this.hosts = [];
+    } else {
+      const placementHosts =
+        currentGroup.placement?.hosts || (currentGroup.spec as any)?.placement?.hosts || [];
+      const currentGroupHosts = new Set<string>(placementHosts);
+
+      this.hosts = (hostList || []).filter((host: Host) => {
+        return currentGroupHosts.has(host.hostname);
       });
+    }
 
-    this.isLoadingHosts = false;
-    this.count = this.hosts.length;
-    this.hostsLoaded.emit(this.count);
+    this.serviceSpec = currentGroup;
+    this.updateCount();
   }
 
-  checkHostsFactsAvailable(): boolean {
-    return this.hostService.checkHostsFactsAvailable(this.orchStatus);
+  private updateCount(): void {
+    this.totalHostCount = this.hosts.length;
+    this.hostsLoaded.emit(this.totalHostCount);
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.html
new file mode 100644 (file)
index 0000000..84db491
--- /dev/null
@@ -0,0 +1,32 @@
+<div>
+  <cd-table
+    [data]="subsystems"
+    [columns]="columns"
+    columnMode="flex"
+    selectionType="none"
+    identifier="nqn"
+    [forceIdentifier]="true"
+    [serverSide]="false"
+    [maxLimit]="25"
+    (updateSelection)="updateSelection($event)"
+    emptyStateTitle="No subsystems linked yet."
+    i18n-emptyStateTitle
+    emptyStateMessage="Once a subsystem is associated, it will appear in this list."
+    i18n-emptyStateMessage
+    [emptyStateIcon]="iconType.emptySearch"
+  >
+  </cd-table>
+</div>
+
+<ng-template #authTpl
+             let-row="data.row">
+  <div cdsStack="horizontal"
+       gap="4">
+  @if (row.auth === authType.NO_AUTH) {
+    <cd-icon type="warning"></cd-icon>
+  } @else {
+    <cd-icon type="success"></cd-icon>
+  }
+  <span class="cds-ml-3">{{ row.auth }}</span>
+  </div>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.spec.ts
new file mode 100644 (file)
index 0000000..3553bee
--- /dev/null
@@ -0,0 +1,100 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+
+import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('NvmeofGatewaySubsystemComponent', () => {
+  let component: NvmeofGatewaySubsystemComponent;
+  let fixture: ComponentFixture<NvmeofGatewaySubsystemComponent>;
+  let nvmeofService: NvmeofService;
+
+  const mockSubsystems: NvmeofSubsystem[] = [
+    {
+      nqn: 'nqn.2014-08.org.nvmexpress:uuid:1111',
+      enable_ha: true,
+      allow_any_host: true,
+      gw_group: 'group1',
+      serial_number: 'SN001',
+      model_number: 'MN001',
+      min_cntlid: 1,
+      max_cntlid: 65519,
+      max_namespaces: 256,
+      namespace_count: 0,
+      subtype: 'NVMe',
+      namespaces: []
+    } as NvmeofSubsystem,
+    {
+      nqn: 'nqn.2014-08.org.nvmexpress:uuid:2222',
+      enable_ha: false,
+      allow_any_host: false,
+      gw_group: 'group1',
+      serial_number: 'SN002',
+      model_number: 'MN002',
+      min_cntlid: 1,
+      max_cntlid: 65519,
+      max_namespaces: 256,
+      namespace_count: 0,
+      subtype: 'NVMe',
+      namespaces: []
+    } as NvmeofSubsystem
+  ];
+
+  const mockInitiators1 = [{ nqn: 'host1' }, { nqn: 'host2' }];
+  const mockInitiators2 = [{ nqn: 'host3' }];
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofGatewaySubsystemComponent],
+      imports: [HttpClientTestingModule, SharedModule],
+      providers: [
+        {
+          provide: NvmeofService,
+          useValue: {
+            listSubsystems: jest.fn(() => of(mockSubsystems)),
+            getInitiators: jest.fn((nqn) => {
+              if (nqn === 'nqn.2014-08.org.nvmexpress:uuid:1111') {
+                return of(mockInitiators1);
+              }
+              return of(mockInitiators2);
+            })
+          }
+        },
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            parent: {
+              params: of({ group: 'group1' })
+            }
+          }
+        }
+      ]
+    }).compileComponents();
+
+    nvmeofService = TestBed.inject(NvmeofService);
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeofGatewaySubsystemComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should verify getData fetches and processes data correctly', () => {
+    component.groupName = 'direct-test-group';
+    component.getSubsystemsData();
+
+    expect(nvmeofService.listSubsystems).toHaveBeenCalledWith('direct-test-group');
+    expect(component.subsystems.length).toBe(2);
+    expect(component.subsystems[0].nqn).toBe(mockSubsystems[0].nqn);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component.ts
new file mode 100644 (file)
index 0000000..aa46f50
--- /dev/null
@@ -0,0 +1,138 @@
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { forkJoin, of } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import {
+  NvmeofSubsystem,
+  NvmeofSubsystemData,
+  NvmeofSubsystemInitiator
+} from '~/app/shared/models/nvmeof';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+
+import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum';
+
+@Component({
+  selector: 'cd-nvmeof-gateway-subsystem',
+  templateUrl: './nvmeof-gateway-subsystem.component.html',
+  styleUrls: ['./nvmeof-gateway-subsystem.component.scss'],
+  standalone: false
+})
+export class NvmeofGatewaySubsystemComponent implements OnInit {
+  @ViewChild('authTpl', { static: true })
+  authTpl!: TemplateRef<any>;
+
+  groupName!: string;
+
+  columns: CdTableColumn[] = [];
+
+  subsystems: NvmeofSubsystemData[] = [];
+  selection = new CdTableSelection();
+
+  iconType = ICON_TYPE;
+  authType = NvmeofSubsystemAuthType;
+
+  constructor(private nvmeofService: NvmeofService, private route: ActivatedRoute) {}
+
+  ngOnInit(): void {
+    this.columns = [
+      {
+        name: $localize`Subsystem NQN`,
+        prop: 'nqn',
+        flexGrow: 2
+      },
+      {
+        name: $localize`Authentication`,
+        prop: 'auth',
+        flexGrow: 1.5,
+        cellTemplate: this.authTpl
+      },
+      {
+        name: $localize`Hosts (Initiators)`,
+        prop: 'hosts',
+        flexGrow: 1
+      }
+    ];
+
+    this.route.parent?.params.subscribe((params) => {
+      if (params['group']) {
+        this.groupName = params['group'];
+        this.getSubsystemsData();
+      }
+    });
+  }
+
+  getSubsystemsData() {
+    this.nvmeofService
+      .listSubsystems(this.groupName)
+      .pipe(
+        switchMap((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => {
+          const subs = Array.isArray(subsystems) ? subsystems : [subsystems];
+          if (subs.length === 0) return of([]);
+
+          return forkJoin(
+            subs.map((sub) =>
+              this.nvmeofService.getInitiators(sub.nqn, this.groupName).pipe(
+                catchError(() => of([])),
+                map(
+                  (
+                    initiators: NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] }
+                  ) => {
+                    let count = 0;
+                    if (Array.isArray(initiators)) count = initiators.length;
+                    else if (initiators?.hosts && Array.isArray(initiators.hosts)) {
+                      count = initiators.hosts.length;
+                    }
+
+                    let authStatus = NvmeofSubsystemAuthType.NO_AUTH;
+                    if (sub.psk) {
+                      authStatus = NvmeofSubsystemAuthType.BIDIRECTIONAL;
+                    } else if (
+                      initiators &&
+                      'hosts' in initiators &&
+                      Array.isArray(initiators.hosts)
+                    ) {
+                      const hasDhchapKey = initiators.hosts.some(
+                        (host: NvmeofSubsystemInitiator) => !!host.dhchap_key
+                      );
+                      if (hasDhchapKey) {
+                        authStatus = NvmeofSubsystemAuthType.UNIDIRECTIONAL;
+                      }
+                    } else if (Array.isArray(initiators)) {
+                      // Fallback for unexpected structure, though getInitiators usually returns {hosts: []}
+                      const hasDhchapKey = (initiators as NvmeofSubsystemInitiator[]).some(
+                        (host: NvmeofSubsystemInitiator) => !!host.dhchap_key
+                      );
+                      if (hasDhchapKey) {
+                        authStatus = NvmeofSubsystemAuthType.UNIDIRECTIONAL;
+                      }
+                    }
+
+                    return {
+                      ...sub,
+                      auth: authStatus,
+                      hosts: count
+                    };
+                  }
+                )
+              )
+            )
+          );
+        })
+      )
+      .subscribe({
+        next: (subsystems: NvmeofSubsystemData[]) => {
+          this.subsystems = subsystems;
+        },
+        error: () => {
+          this.subsystems = [];
+        }
+      });
+  }
+
+  updateSelection(selection: CdTableSelection): void {
+    this.selection = selection;
+  }
+}
index 306dca8d5472529d6387ca2eef4871ae5bc60f25..b9233e0a46130570276768979b5a76d0817ca38c 100644 (file)
@@ -14,7 +14,7 @@ import { CdDevice } from '../models/devices';
 import { SmartDataResponseV1 } from '../models/smart';
 import { DeviceService } from '../services/device.service';
 import { Host } from '../models/host.interface';
-import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { OrchestratorStatus } from '../models/orchestrator.interface';
 
 @Injectable({
   providedIn: 'root'
@@ -170,10 +170,9 @@ export class HostService extends ApiClient {
     return this.http.get<Host[]>(`${this.baseUIURL}/list`);
   }
 
-  checkHostsFactsAvailable(orchStatus?: OrchestratorStatus): boolean {
-    const orchFeatures = orchStatus?.features;
-    if (!_.isEmpty(orchFeatures)) {
-      return !!orchFeatures.get_facts?.available;
+  checkHostsFactsAvailable(orchStatus: OrchestratorStatus) {
+    if (orchStatus?.available) {
+      return true;
     }
     return false;
   }
index d74e7e0fb202f9a73a73bd58f15c6445fbf50fe9..5aecd4cb76f2cca4840535b6c312790bf03b83cb 100644 (file)
@@ -2,9 +2,10 @@ import { Injectable } from '@angular/core';
 import { HttpClient } from '@angular/common/http';
 
 import _ from 'lodash';
-import { Observable, of as observableOf } from 'rxjs';
+import { Observable, forkJoin, of as observableOf } from 'rxjs';
 import { catchError, map, mapTo } from 'rxjs/operators';
 import { CephServiceSpec } from '../models/service.interface';
+import { HostService } from './host.service';
 
 export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512;
 
@@ -48,7 +49,14 @@ const UI_API_PATH = 'ui-api/nvmeof';
   providedIn: 'root'
 })
 export class NvmeofService {
-  constructor(private http: HttpClient) {}
+  constructor(private http: HttpClient, private hostService: HostService) {}
+
+  fetchHostsAndGroups() {
+    return forkJoin({
+      groups: this.listGatewayGroups(),
+      hosts: this.hostService.getAllHosts()
+    });
+  }
 
   // formats the gateway groups to be consumed for combobox item
   formatGwGroupsList(
index 69845644dbdf9e1b97f9c27038845ef5db09f632..c7d2258e8801fe98c1dabd861bcdc0430a63b247 100644 (file)
@@ -110,6 +110,7 @@ import { Close16 } from '@carbon/icons';
 import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
 import { ProductiveCardComponent } from './productive-card/productive-card.component';
 import { PageHeaderComponent } from './page-header/page-header.component';
+import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component';
 
 @NgModule({
   imports: [
@@ -208,7 +209,8 @@ import { PageHeaderComponent } from './page-header/page-header.component';
     TearsheetComponent,
     TearsheetStepComponent,
     ProductiveCardComponent,
-    PageHeaderComponent
+    PageHeaderComponent,
+    SidebarLayoutComponent
   ],
   providers: [provideCharts(withDefaultRegisterables())],
   exports: [
@@ -255,7 +257,8 @@ import { PageHeaderComponent } from './page-header/page-header.component';
     TearsheetComponent,
     TearsheetStepComponent,
     ProductiveCardComponent,
-    PageHeaderComponent
+    PageHeaderComponent,
+    SidebarLayoutComponent
   ]
 })
 export class ComponentsModule {
index 7f88eb3dd0b204596095e5a65bda62b2fe2e23b6..28d9c1cfe18fbade801f7abe892038947d83a82d 100644 (file)
@@ -46,3 +46,7 @@ Using `color` in css and seyting svg will fill="currentColor does not work.
 .notificationNew-icon circle {
   fill: theme.$support-error !important;
 }
+
+.emptySearch-icon {
+  fill: theme.$layer-selected-disabled !important;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.html
new file mode 100644 (file)
index 0000000..0038957
--- /dev/null
@@ -0,0 +1,23 @@
+@if (title) {
+<header class="sidebar-header">
+  <h2 class="cds--type-heading-05">{{ title }}</h2>
+</header>
+}
+<div class="sidebar-layout-container">
+  <div class="sidebar-layout-shell">
+    <cds-sidenav [expanded]="true">
+      @for (item of items; track item.label) {
+      <cds-sidenav-item
+        [route]="item.route"
+        [useRouter]="true"
+        [routerLinkActiveOptions]="item.routerLinkActiveOptions || { exact: false }">
+        <span class="cds--type-heading-compact-01">{{ item.label }}</span>
+      </cds-sidenav-item>
+      }
+    </cds-sidenav>
+
+    <main class="sidebar-layout-main">
+      <router-outlet></router-outlet>
+    </main>
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.scss
new file mode 100644 (file)
index 0000000..92b594e
--- /dev/null
@@ -0,0 +1,22 @@
+@use './src/styles/vendor/variables' as vv;
+@use '@carbon/colors';
+@use '@carbon/layout';
+
+.sidebar-layout-container {
+  min-height: calc(100vh - (vv.$navbar-height + layout.rem(55px)));
+  background-color: var(--cds-background);
+  padding-right: var(--cds-spacing-07);
+}
+
+.sidebar-layout-shell {
+  transform: translate(0);
+  position: relative;
+}
+
+.sidebar-layout-main {
+  margin-left: layout.rem(272px);
+}
+
+.sidebar-header {
+  padding-left: var(--cds-spacing-05);
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.spec.ts
new file mode 100644 (file)
index 0000000..47e74a6
--- /dev/null
@@ -0,0 +1,26 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+import { SidebarLayoutComponent } from './sidebar-layout.component';
+
+describe('SidebarLayoutComponent', () => {
+  let component: SidebarLayoutComponent;
+  let fixture: ComponentFixture<SidebarLayoutComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [SidebarLayoutComponent],
+      imports: [RouterTestingModule, SideNavModule, ThemeModule]
+    }).compileComponents();
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(SidebarLayoutComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/sidebar-layout/sidebar-layout.component.ts
new file mode 100644 (file)
index 0000000..971a872
--- /dev/null
@@ -0,0 +1,22 @@
+import { Component, Input, ViewEncapsulation } from '@angular/core';
+
+export interface SidebarItem {
+  label: string;
+  route: string[];
+  routerLinkActiveOptions?: { exact: boolean };
+}
+
+@Component({
+  selector: 'cd-sidebar-layout',
+  templateUrl: './sidebar-layout.component.html',
+  styleUrls: ['./sidebar-layout.component.scss'],
+  encapsulation: ViewEncapsulation.None,
+  standalone: false,
+  host: {
+    class: 'tearsheet--full'
+  }
+})
+export class SidebarLayoutComponent {
+  @Input() title!: string;
+  @Input() items: SidebarItem[] = [];
+}
index 08cc137916b937cd6c8ac3bdb9583fd1a48020bd..7de826aa2e3a2d66c0620264fcce20e8be957136 100644 (file)
@@ -33,6 +33,7 @@ export enum URLVerbs {
   /* Non-standard verbs */
   COPY = 'copy',
   CLONE = 'clone',
+  VIEW = 'view',
 
   /* Prometheus wording */
   RECREATE = 'recreate',
@@ -87,7 +88,8 @@ export enum ActionLabels {
 
   /* Multi-cluster */
   CONNECT = 'connect',
-  RECONNECT = 'reconnect'
+  RECONNECT = 'reconnect',
+  VIEW = 'View'
 }
 
 @Injectable({
@@ -162,7 +164,7 @@ export class ActionLabelsI18n {
   EXPAND_CLUSTER: string;
   SETUP_MULTISITE_REPLICATION: string;
   NFS_EXPORT: string;
-
+  VIEW: string;
   constructor() {
     /* Create a new item */
     this.CREATE = $localize`Create`;
@@ -254,6 +256,7 @@ export class ActionLabelsI18n {
     this.EXPAND_CLUSTER = $localize`Expand Cluster`;
 
     this.NFS_EXPORT = $localize`Create NFS Export`;
+    this.VIEW = $localize`View`;
   }
 }
 
index 67f8da625c2f2f23abf7d0cda0b45be8c402ad72..1e6b926ed88cec17ea244d43f6d4c92119f149ee 100644 (file)
@@ -104,7 +104,8 @@ export enum Icons {
   notification = 'notification',
   error = 'error--filled',
   notificationOff = 'notification--off',
-  notificationNew = 'notification--new'
+  notificationNew = 'notification--new',
+  emptySearch = 'search'
 }
 
 export enum IconSize {
@@ -127,5 +128,6 @@ export const ICON_TYPE = {
   notificationNew: 'notification--new',
   success: 'success',
   warning: 'warning',
-  add: 'add'
+  add: 'add',
+  emptySearch: 'emptySearch'
 } as const;
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/nvmeof.enum.ts
new file mode 100644 (file)
index 0000000..816613d
--- /dev/null
@@ -0,0 +1,10 @@
+export enum NvmeofSubsystemAuthType {
+  NO_AUTH = 'No authentication',
+  UNIDIRECTIONAL = 'Unidirectional',
+  BIDIRECTIONAL = 'Bi-directional'
+}
+
+export enum NvmeofGatewayNodeMode {
+  SELECTOR = 'selector',
+  DETAILS = 'details'
+}
index 976435853eda9197bf966894e4d9f90d936ee21f..59bd8c1c72531d882ef9421faa0f18db45b68066 100644 (file)
@@ -19,10 +19,21 @@ export interface NvmeofSubsystem {
   namespace_count: number;
   subtype: string;
   max_namespaces: number;
+  allow_any_host?: boolean;
+  enable_ha?: boolean;
+  gw_group?: string;
+  initiator_count?: number;
+  psk?: string;
+}
+
+export interface NvmeofSubsystemData extends NvmeofSubsystem {
+  auth?: string;
+  hosts?: number;
 }
 
 export interface NvmeofSubsystemInitiator {
   nqn: string;
+  dhchap_key?: string;
 }
 
 export interface NvmeofListener {
index b63489b8bcecdce83f7d8a89accc62b67a549936..9b750e7fe5cccab6b717bba7065cb356687f7f8a 100644 (file)
@@ -55,4 +55,8 @@ export class URLBuilderService {
   getReconnect(item: string, absolute = true): string {
     return this.getURL(URLVerbs.RECONNECT, absolute, item);
   }
+
+  getView(absolute = true): string {
+    return this.getURL(URLVerbs.VIEW, absolute);
+  }
 }
index fa9cf5cb746b3d2f3db77d1910581d64c834d20f..20155ce6bb5e5908104260367bbcedb2472ca0f4 100644 (file)
@@ -65,3 +65,7 @@
 .cds-mr-5 {
   margin-right: layout.$spacing-05;
 }
+
+.cds-pt-6 {
+  padding-top: layout.$spacing-06;
+}