]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: NVMeof-Create Gatway group Form 66546/head
authorpujashahu <pshahu@redhat.com>
Mon, 8 Dec 2025 07:09:17 +0000 (12:39 +0530)
committerpujaoshahu <pshahu@redhat.com>
Tue, 27 Jan 2026 16:59:08 +0000 (22:29 +0530)
Fixes: https://tracker.ceph.com/issues/74134
Signed-off-by: pujaoshahu <pshahu@redhat.com>
16 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
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 [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/host.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/host-status.enum.ts [new file with mode: 0644]

index a0278e60b1ec93e94c5775e756719263895515b5..9725b2ab6bd4cf50772a1434f621e8771f797ed1 100644 (file)
@@ -76,6 +76,8 @@ 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 { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
+import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
+import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
 
 @NgModule({
   imports: [
@@ -103,7 +105,8 @@ import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gatew
     DatePickerModule,
     ComboBoxModule,
     TabsModule,
-    TagModule
+    TagModule,
+    GridModule
   ],
   declarations: [
     RbdListComponent,
@@ -140,7 +143,9 @@ import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gatew
     NvmeofNamespacesListComponent,
     NvmeofNamespacesFormComponent,
     NvmeofInitiatorsListComponent,
-    NvmeofInitiatorsFormComponent
+    NvmeofInitiatorsFormComponent,
+    NvmeofGatewayNodeComponent,
+    NvmeofGroupFormComponent
   ],
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
@@ -294,7 +299,12 @@ const routes: Routes = [
     },
     children: [
       { path: '', redirectTo: 'gateways', pathMatch: 'full' },
-      { path: '', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } },
+      { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } },
+      {
+        path: `gateways/${URLVerbs.CREATE}`,
+        component: NvmeofGroupFormComponent,
+        data: { breadcrumbs: `${ActionLabels.CREATE}${URLVerbs.GATEWAY_GROUP}` }
+      },
       {
         path: 'subsystems',
         component: NvmeofSubsystemsComponent,
index b17aa751ce5551708d6877981836e9b44cbdfac0..7d6eec88134d878741fb13b729fa96adc0bdd369 100644 (file)
@@ -2,6 +2,7 @@ import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@a
 import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
 import { catchError, map, switchMap, tap } 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';
 import { TableComponent } from '~/app/shared/datatable/table/table.component';
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
@@ -21,13 +22,17 @@ import { FinishedTask } from '~/app/shared/models/finished-task';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { URLBuilderService } from '~/app/shared/services/url-builder.service';
+
+const BASE_URL = 'block/nvmeof/gateways';
 
 @Component({
   selector: 'cd-nvmeof-gateway-group',
   templateUrl: './nvmeof-gateway-group.component.html',
   styleUrls: ['./nvmeof-gateway-group.component.scss'],
   standalone: false,
-  encapsulation: ViewEncapsulation.None
+  encapsulation: ViewEncapsulation.None,
+  providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
 })
 export class NvmeofGatewayGroupComponent implements OnInit {
   @ViewChild(TableComponent, { static: true })
@@ -44,6 +49,7 @@ export class NvmeofGatewayGroupComponent implements OnInit {
 
   permission: Permission;
   tableActions: CdTableAction[];
+  nodesAvailable = false;
   columns: CdTableColumn[] = [];
   selection: CdTableSelection = new CdTableSelection();
   gatewayGroup$: Observable<CephServiceSpec[]>;
@@ -61,10 +67,12 @@ export class NvmeofGatewayGroupComponent implements OnInit {
     public actionLabels: ActionLabelsI18n,
     private authStorageService: AuthStorageService,
     private nvmeofService: NvmeofService,
+    private hostService: HostService,
     public modalService: ModalCdsService,
     private cephServiceService: CephServiceService,
     public taskWrapper: TaskWrapperService,
-    private notificationService: NotificationService
+    private notificationService: NotificationService,
+    private urlBuilder: URLBuilderService
   ) {}
 
   ngOnInit(): void {
@@ -90,10 +98,11 @@ export class NvmeofGatewayGroupComponent implements OnInit {
         cellTemplate: this.dateTpl
       }
     ];
-
     const createAction: CdTableAction = {
       permission: 'create',
       icon: Icons.add,
+      disable: () => (this.nodesAvailable ? false : $localize`Gateway nodes are not available`),
+      routerLink: () => this.urlBuilder.getCreate(),
       name: this.actionLabels.CREATE,
       canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
     };
@@ -118,9 +127,16 @@ export class NvmeofGatewayGroupComponent implements OnInit {
               groups.map((group: NvmeofGatewayGroup) => {
                 const isRunning = (group.status?.running ?? 0) > 0;
                 const subsystemsObservable = isRunning
-                  ? this.nvmeofService
-                      .listSubsystems(group.spec.group)
-                      .pipe(catchError(() => of([])))
+                  ? 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([]);
+                      })
+                    )
                   : of([]);
 
                 return subsystemsObservable.pipe(
@@ -139,24 +155,23 @@ export class NvmeofGatewayGroupComponent implements OnInit {
               })
             );
           }),
-          catchError((error) => {
+          catchError(() => {
             this.notificationService.show(
               NotificationType.error,
               $localize`Unable to fetch Gateway group`,
               $localize`Gateway group does not exist`
             );
-            if (error?.preventDefault) {
-              error.preventDefault();
-            }
             return of([]);
           })
         )
       )
     );
+    this.checkNodesAvailability();
   }
 
   fetchData(): void {
     this.subject.next([]);
+    this.checkNodesAvailability();
   }
 
   updateSelection(selection: CdTableSelection): void {
@@ -206,4 +221,27 @@ export class NvmeofGatewayGroupComponent implements OnInit {
       }
     });
   }
+
+  private checkNodesAvailability(): void {
+    forkJoin([this.nvmeofService.listGatewayGroups(), this.hostService.getAllHosts()]).subscribe(
+      ([groups, hosts]: [GatewayGroup[][], any[]]) => {
+        const usedHosts = new Set<string>();
+        const groupList = groups?.[0] ?? [];
+        groupList.forEach((group: CephServiceSpec) => {
+          const placementHosts = group.placement?.hosts || [];
+          placementHosts.forEach((hostname: string) => usedHosts.add(hostname));
+        });
+
+        const availableHosts = (hosts || []).filter((host) => {
+          const hostname = host.hostname;
+          return hostname && !usedHosts.has(hostname);
+        });
+
+        this.nodesAvailable = availableHosts.length > 0;
+      },
+      () => {
+        this.nodesAvailable = false;
+      }
+    );
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html
new file mode 100644 (file)
index 0000000..7dc0174
--- /dev/null
@@ -0,0 +1,54 @@
+
+<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>
+
+<ng-template
+  #addrTpl
+  let-value="data.value"
+>
+  <span>{{ value || '-' }}</span>
+
+</ng-template>
+
+<ng-template
+  #statusTpl
+  let-value="data.value"
+  let-row="data.row"
+>
+  <div
+    [cdsStack]="'horizontal'"
+    gap="4"
+  >
+  @if (value === HostStatus.AVAILABLE) {
+    <cd-icon type="success"></cd-icon>
+  }
+
+  <span class="cds-ml-3">{{ value | titlecase }}</span>
+  </div>
+
+</ng-template>
+
+<ng-template #labelsTpl
+             let-value="data.value">
+@if (value && value.length > 0) {
+  <cds-tag *ngFor="let label of value"
+           class="tag tag-dark">{{ label }}</cds-tag>
+} @else {
+  <span>-</span>
+}
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts
new file mode 100644 (file)
index 0000000..da5ad19
--- /dev/null
@@ -0,0 +1,461 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+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';
+import { HostService } from '~/app/shared/api/host.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { HostStatus } from '~/app/shared/enum/host-status.enum';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TagModule } from 'carbon-components-angular';
+import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node.component';
+
+describe('NvmeofGatewayNodeComponent', () => {
+  let component: NvmeofGatewayNodeComponent;
+  let fixture: ComponentFixture<NvmeofGatewayNodeComponent>;
+  let hostService: HostService;
+  let orchService: OrchestratorService;
+  let nvmeofService: NvmeofService;
+
+  const fakeAuthStorageService = {
+    getPermissions: () => {
+      return new Permissions({ nvmeof: ['read', 'update', 'create', 'delete'] });
+    }
+  };
+
+  const mockGatewayNodes = [
+    {
+      hostname: 'gateway-node-1',
+      addr: '192.168.1.10',
+      status: HostStatus.AVAILABLE,
+      labels: ['nvmeof', 'gateway'],
+      services: [
+        {
+          type: 'nvmeof-gw',
+          id: 'gateway-1'
+        }
+      ],
+      ceph_version: 'ceph version 18.0.0',
+      sources: {
+        ceph: true,
+        orchestrator: true
+      },
+      service_instances: [] as any[]
+    },
+    {
+      hostname: 'gateway-node-2',
+      addr: '192.168.1.11',
+      status: HostStatus.MAINTENANCE,
+      labels: ['nvmeof'],
+      services: [
+        {
+          type: 'nvmeof-gw',
+          id: 'gateway-2'
+        }
+      ],
+      ceph_version: 'ceph version 18.0.0',
+      sources: {
+        ceph: true,
+        orchestrator: true
+      },
+      service_instances: [] as any[]
+    },
+    {
+      hostname: 'gateway-node-3',
+      addr: '192.168.1.12',
+      status: '',
+      labels: [],
+      services: [],
+      ceph_version: 'ceph version 18.0.0',
+      sources: {
+        ceph: true,
+        orchestrator: false
+      },
+      service_instances: [] as any[]
+    }
+  ];
+
+  configureTestBed({
+    imports: [
+      BrowserAnimationsModule,
+      CephSharedModule,
+      SharedModule,
+      HttpClientTestingModule,
+      RouterTestingModule,
+      CephModule,
+      CoreModule,
+      TagModule
+    ],
+    providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeofGatewayNodeComponent);
+    component = fixture.componentInstance;
+    hostService = TestBed.inject(HostService);
+    orchService = TestBed.inject(OrchestratorService);
+    nvmeofService = TestBed.inject(NvmeofService);
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should initialize columns on component init', () => {
+    component.ngOnInit();
+
+    expect(component.columns).toBeDefined();
+    expect(component.columns.length).toBeGreaterThan(0);
+    expect(component.columns[0].name).toBe('Hostname');
+    expect(component.columns[0].prop).toBe('hostname');
+  });
+
+  it('should have all required columns defined', () => {
+    component.ngOnInit();
+
+    const columnProps = component.columns.map((col) => col.prop);
+    expect(columnProps).toContain('hostname');
+    expect(columnProps).toContain('addr');
+    expect(columnProps).toContain('status');
+    expect(columnProps).toContain('labels');
+  });
+
+  it('should initialize with default values', () => {
+    expect(component.hosts).toEqual([]);
+    expect(component.isLoadingHosts).toBe(false);
+    expect(component.count).toBe(5);
+    expect(component.permission).toBeDefined();
+  });
+
+  it('should update selection', () => {
+    const selection = new CdTableSelection();
+    selection.selected = [mockGatewayNodes[0]];
+
+    component.updateSelection(selection);
+
+    expect(component.selection).toBe(selection);
+    expect(component.selection.selected.length).toBe(1);
+  });
+
+  it('should get selected hosts', () => {
+    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));
+
+    expect(selectedHosts.length).toBe(2);
+    expect(selectedHosts[0]).toEqual(mockGatewayNodes[0]);
+    expect(selectedHosts[1]).toEqual(mockGatewayNodes[1]);
+  });
+
+  it('should get selected hostnames', () => {
+    component.selection = new CdTableSelection();
+    component.selection.selected = [mockGatewayNodes[0], mockGatewayNodes[1]];
+
+    const selectedHostnames = component.getSelectedHostnames();
+
+    expect(selectedHostnames).toEqual(['gateway-node-1', 'gateway-node-2']);
+  });
+
+  it('should load hosts with orchestrator available and facts feature enabled', (done) => {
+    const hostListSpy = spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
+    const mockOrcStatus: any = {
+      available: true,
+      features: new Map([['get_facts', { available: true }]])
+    };
+
+    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(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    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);
+  });
+
+  it('should set count to hosts length', (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(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    fixture.detectChanges();
+
+    component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+    setTimeout(() => {
+      // Count should equal the filtered hosts length
+      expect(component.count).toBe(component.hosts.length);
+      done();
+    }, 100);
+  });
+
+  it('should set count to 0 when no hosts are returned', (done) => {
+    spyOn(hostService, 'list').and.returnValue(of([]));
+    const mockOrcStatus: any = {
+      available: true,
+      features: new Map()
+    };
+
+    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(component.count).toBe(0);
+      expect(component.hosts.length).toBe(0);
+      done();
+    }, 100);
+  });
+
+  it('should handle error when fetching hosts', (done) => {
+    const errorMsg = 'Failed to fetch hosts';
+    spyOn(hostService, 'list').and.returnValue(throwError(() => new Error(errorMsg)));
+    const mockOrcStatus: any = {
+      available: true,
+      features: new Map()
+    };
+
+    spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
+    spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+    spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    fixture.detectChanges();
+
+    const context = new CdTableFetchDataContext(() => undefined);
+    spyOn(context, 'error');
+
+    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);
+  });
+
+  it('should not re-fetch if already loading', (done) => {
+    component.isLoadingHosts = true;
+    const hostListSpy = spyOn(hostService, 'list');
+
+    component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+    setTimeout(() => {
+      expect(hostListSpy).not.toHaveBeenCalled();
+      done();
+    }, 100);
+  });
+
+  it('should unsubscribe on component destroy', () => {
+    const destroy$ = component['destroy$'];
+    spyOn(destroy$, 'next');
+    spyOn(destroy$, 'complete');
+
+    component.ngOnDestroy();
+
+    expect(destroy$.next).toHaveBeenCalled();
+    expect(destroy$.complete).toHaveBeenCalled();
+  });
+
+  it('should handle host list with various label types', (done) => {
+    const hostsWithLabels = [
+      {
+        ...mockGatewayNodes[0],
+        labels: ['nvmeof', 'gateway', 'high-priority']
+      },
+      {
+        ...mockGatewayNodes[2],
+        labels: []
+      }
+    ];
+
+    spyOn(hostService, 'list').and.returnValue(of(hostsWithLabels));
+    const mockOrcStatus: any = {
+      available: true,
+      features: new Map()
+    };
+
+    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(component.hosts[0]['labels'].length).toBe(3);
+      expect(component.hosts[1]['labels'].length).toBe(0);
+      done();
+    }, 100);
+  });
+
+  it('should handle hosts with multiple services', (done) => {
+    const hostsWithServices = [
+      {
+        ...mockGatewayNodes[0],
+        services: [
+          { type: 'nvmeof-gw', id: 'gateway-1' },
+          { type: 'mon', id: '0' }
+        ]
+      }
+    ];
+
+    spyOn(hostService, 'list').and.returnValue(of(hostsWithServices));
+    const mockOrcStatus: any = {
+      available: true,
+      features: new Map()
+    };
+
+    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(component.hosts[0]['services'].length).toBe(2);
+      done();
+    }, 100);
+  });
+
+  it('should initialize table context on first getHosts call', (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(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    fixture.detectChanges();
+
+    expect((component as any).tableContext).toBeNull();
+
+    component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+    setTimeout(() => {
+      expect((component as any).tableContext).not.toBeNull();
+      done();
+    }, 100);
+  });
+
+  it('should reuse table context if already set', (done) => {
+    const context = new CdTableFetchDataContext(() => undefined);
+    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(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+    fixture.detectChanges();
+
+    component.getHosts(context);
+
+    setTimeout(() => {
+      const storedContext = (component as any).tableContext;
+      expect(storedContext).toBe(context);
+      done();
+    }, 100);
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts
new file mode 100644 (file)
index 0000000..69d6147
--- /dev/null
@@ -0,0 +1,197 @@
+import {
+  Component,
+  EventEmitter,
+  OnDestroy,
+  OnInit,
+  Output,
+  TemplateRef,
+  ViewChild
+} from '@angular/core';
+import { forkJoin, Subject } from 'rxjs';
+import { map, mergeMap, takeUntil } 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 { 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';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { Permission } from '~/app/shared/models/permissions';
+
+import { Host } from '~/app/shared/models/host.interface';
+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',
+  styleUrls: ['./nvmeof-gateway-node.component.scss'],
+  standalone: false
+})
+export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
+  @ViewChild(TableComponent, { static: true })
+  table: TableComponent;
+
+  @ViewChild('hostNameTpl', { static: true })
+  hostNameTpl: TemplateRef<any>;
+
+  @ViewChild('statusTpl', { static: true })
+  statusTpl: TemplateRef<any>;
+
+  @ViewChild('addrTpl', { static: true })
+  addrTpl: TemplateRef<any>;
+
+  @ViewChild('labelsTpl', { static: true })
+  labelsTpl: TemplateRef<any>;
+
+  @ViewChild('orchTmpl', { static: true })
+  orchTmpl: TemplateRef<any>;
+
+  @Output() selectionChange = new EventEmitter<CdTableSelection>();
+  @Output() hostsLoaded = new EventEmitter<number>();
+
+  usedHostnames: Set<string> = new Set();
+
+  permission: Permission;
+  columns: CdTableColumn[] = [];
+  hosts: Host[] = [];
+  isLoadingHosts = false;
+  tableActions: CdTableAction[];
+  selection = new CdTableSelection();
+  icons = Icons;
+  HostStatus = HostStatus;
+  private tableContext: CdTableFetchDataContext = null;
+  count = 5;
+  orchStatus: OrchestratorStatus;
+  private destroy$ = new Subject<void>();
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    private hostService: HostService,
+    private orchService: OrchestratorService,
+    private nvmeofService: NvmeofService
+  ) {
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+  }
+
+  ngOnInit(): void {
+    this.columns = [
+      {
+        name: $localize`Hostname`,
+        prop: 'hostname',
+        flexGrow: 1,
+        cellTemplate: this.hostNameTpl
+      },
+      {
+        name: $localize`IP address`,
+        prop: 'addr',
+        flexGrow: 0.8,
+        cellTemplate: this.addrTpl
+      },
+      {
+        name: $localize`Status`,
+        prop: 'status',
+        flexGrow: 0.8,
+        cellTemplate: this.statusTpl
+      },
+      {
+        name: $localize`Labels`,
+        prop: 'labels',
+        flexGrow: 1,
+        cellTemplate: this.labelsTpl
+      }
+    ];
+  }
+
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+
+  updateSelection(selection: CdTableSelection): void {
+    this.selection = selection;
+    this.selectionChange.emit(selection);
+  }
+
+  getSelectedHostnames(): string[] {
+    return this.selection.selected.map((host: Host) => host.hostname);
+  }
+
+  getHosts(context: CdTableFetchDataContext): void {
+    if (context !== null) {
+      this.tableContext = context;
+    }
+    if (this.tableContext == null) {
+      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),
+        () => {
+          this.isLoadingHosts = false;
+          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;
+      })
+    );
+  }
+
+  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());
+      })
+    );
+  }
+
+  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;
+      });
+
+    this.isLoadingHosts = false;
+    this.count = this.hosts.length;
+    this.hostsLoaded.emit(this.count);
+  }
+
+  checkHostsFactsAvailable(): boolean {
+    return this.hostService.checkHostsFactsAvailable(this.orchStatus);
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html
new file mode 100644 (file)
index 0000000..9c498ec
--- /dev/null
@@ -0,0 +1,129 @@
+<form
+  #formDir="ngForm"
+  [formGroup]="groupForm"
+  novalidate
+>
+  <div cdsGrid
+       [useCssGrid]="true"
+       [narrow]="true"
+       [fullWidth]="true">
+    <div cdsCol
+         [columnNumbers]="{sm: 4, md: 8}">
+      <div cdsRow
+           class="form-heading form-item">
+      <h3>{{ action | titlecase }} {{ resource }}</h3>
+      <cd-help-text>
+        <span i18n>
+           A logical group of gateways that hosts will connect to.
+        </span>
+      </cd-help-text>
+      <cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
+      </div>
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-text-label
+            labelInputID="name"
+            i18n
+            i18n-helperText
+            helperText="A unique name to identify this gateway group."
+            cdRequiredField="Gateway group name"
+            [invalid]="groupName.isInvalid">
+            Gateway group name
+            <input
+              cdsText
+              cdValidate
+              type="text"
+              id="groupName"
+              #groupName="cdValidate"
+              autofocus
+              formControlName="groupName"
+              [invalid]="groupName.isInvalid"
+            />
+          </cds-text-label>
+          <span
+            class="invalid-feedback"
+            *ngIf="groupForm.showError('groupName', formDir, 'required')"
+            i18n>This field is required.</span>
+          <span
+            class="invalid-feedback"
+            *ngIf="groupForm.get('groupName')?.hasError('notUnique') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+            i18n>Group name must be unique.</span>
+          <span
+            class="invalid-feedback"
+            *ngIf="groupForm.get('groupName')?.hasError('invalidChars') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+            i18n>Special characters are not allowed.</span>
+
+        </div>
+      </div>
+      <!-- Pool -->
+      <div cdsRow
+           class="form-item">
+        <div cdsCol>
+          <cds-select
+            label="Block pool"
+            helperText="An RBD application-enabled pool in which the gateway configuration can be managed."
+            labelInputID="pool"
+            id="pool"
+            formControlName="pool"
+            cdRequiredField="Block pool"
+            [invalid]="groupForm.controls.pool.invalid && groupForm.controls.pool.dirty"
+            [invalidText]="poolError"
+            i18n-label
+            i18n-helperText
+          >
+            <option *ngIf="poolsLoading"
+                    [ngValue]="null"
+                    i18n>Loading...</option>
+            <option *ngIf="!poolsLoading && pools.length === 0"
+                    [ngValue]="null"
+                    i18n>-- No block pools available --</option>
+            <option *ngFor="let pool of pools"
+                    [value]="pool.pool_name">{{ pool.pool_name }}</option>
+          </cds-select>
+          <ng-template #poolError>
+            <span class="invalid-feedback"
+                  *ngIf="groupForm.showError('pool', formDir, 'required')"
+                  i18n>This field is required.</span>
+          </ng-template>
+        </div>
+      </div>
+
+      <!-- Target Nodes Selection -->
+      <div
+        cdsRow
+        class="form-item"
+      >
+      <div cdsCol>
+        <h1 class="cds--type-heading-02">Select target nodes</h1>
+      <cd-help-text>
+        <span i18n>
+          Gateway nodes to run NVMe-oF target pods/services
+        </span>
+      </cd-help-text>
+      </div>
+      <div
+        cdsCol
+        class="cds-pt-3 cds-pb-3"
+      >
+        <cd-nvmeof-gateway-node
+          (hostsLoaded)="onHostsLoaded($event)"
+        ></cd-nvmeof-gateway-node>
+      </div>
+      </div>
+
+      <div cdsRow>
+        <cd-form-button-panel
+          (submitActionEvent)="onSubmit()"
+          [form]="groupForm"
+          [submitText]="(action | titlecase) + ' ' + (resource)"
+          [disabled]="isCreateDisabled"
+          wrappingClass="text-right form-button"
+        >
+        </cd-form-button-panel>
+      </div>
+    </div>
+  </div>
+</form>
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts
new file mode 100644 (file)
index 0000000..f3fae17
--- /dev/null
@@ -0,0 +1,224 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+
+import { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
+import { GridModule, InputModule, SelectModule } from 'carbon-components-angular';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FormHelper } from '~/testing/unit-test-helper';
+
+describe('NvmeofGroupFormComponent', () => {
+  let component: NvmeofGroupFormComponent;
+  let fixture: ComponentFixture<NvmeofGroupFormComponent>;
+  let form: CdFormGroup;
+  let formHelper: FormHelper;
+  let poolService: PoolService;
+  let taskWrapperService: TaskWrapperService;
+  let cephServiceService: CephServiceService;
+  let router: Router;
+
+  const mockPools = [
+    { pool_name: 'rbd', application_metadata: ['rbd'] },
+    { pool_name: 'rbd', application_metadata: ['rbd'] },
+    { pool_name: 'pool2', application_metadata: ['rgw'] }
+  ];
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofGroupFormComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgbTypeaheadModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule,
+        GridModule,
+        InputModule,
+        SelectModule,
+        ToastrModule.forRoot()
+      ],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofGroupFormComponent);
+    component = fixture.componentInstance;
+    poolService = TestBed.inject(PoolService);
+    taskWrapperService = TestBed.inject(TaskWrapperService);
+    cephServiceService = TestBed.inject(CephServiceService);
+    router = TestBed.inject(Router);
+
+    spyOn(poolService, 'list').and.returnValue(Promise.resolve(mockPools));
+
+    component.ngOnInit();
+    form = component.groupForm;
+    formHelper = new FormHelper(form);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should initialize form with empty fields', () => {
+    expect(form.controls.groupName.value).toBeNull();
+    expect(form.controls.unmanaged.value).toBe(false);
+  });
+
+  it('should set action to CREATE on init', () => {
+    expect(component.action).toBe('Create');
+  });
+
+  it('should set resource to gateway group', () => {
+    expect(component.resource).toBe('gateway group');
+  });
+
+  describe('form validation', () => {
+    it('should require groupName', () => {
+      formHelper.setValue('groupName', '');
+      formHelper.expectError('groupName', 'required');
+    });
+
+    it('should require pool', () => {
+      formHelper.setValue('pool', null);
+      formHelper.expectError('pool', 'required');
+    });
+
+    it('should be valid when groupName and pool are set', () => {
+      formHelper.setValue('groupName', 'test-group');
+      formHelper.setValue('pool', 'rbd');
+      expect(form.valid).toBe(true);
+    });
+  });
+
+  describe('loadPools', () => {
+    it('should load pools and filter by rbd application metadata', fakeAsync(() => {
+      component.loadPools();
+      tick();
+      expect(component.pools.length).toBe(2);
+      expect(component.pools.map((p) => p.pool_name)).toEqual(['rbd', 'rbd']);
+    }));
+
+    it('should set default pool to rbd if available', fakeAsync(() => {
+      component.groupForm.get('pool').setValue(null);
+      component.loadPools();
+      tick();
+      expect(component.groupForm.get('pool').value).toBe('rbd');
+    }));
+
+    it('should set first pool if rbd is not available', fakeAsync(() => {
+      component.groupForm.get('pool').setValue(null);
+      const poolsWithoutRbd = [{ pool_name: 'custom-pool', application_metadata: ['rbd'] }];
+      (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve(poolsWithoutRbd));
+      component.loadPools();
+      tick();
+      expect(component.groupForm.get('pool').value).toBe('custom-pool');
+    }));
+
+    it('should handle empty pools', fakeAsync(() => {
+      (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve([]));
+      component.loadPools();
+      tick();
+      expect(component.pools.length).toBe(0);
+      expect(component.poolsLoading).toBe(false);
+    }));
+
+    it('should handle pool loading error', fakeAsync(() => {
+      (poolService.list as jasmine.Spy).and.returnValue(Promise.reject('error'));
+      component.loadPools();
+      tick();
+      expect(component.pools).toEqual([]);
+      expect(component.poolsLoading).toBe(false);
+    }));
+  });
+
+  describe('onSubmit', () => {
+    beforeEach(() => {
+      spyOn(cephServiceService, 'create').and.returnValue(of({}));
+      spyOn(taskWrapperService, 'wrapTaskAroundCall').and.callFake(({ call }) => call);
+      spyOn(router, 'navigateByUrl');
+    });
+
+    it('should not call create if no hosts are selected', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [],
+        getSelectedHostnames: (): string[] => []
+      } as any;
+
+      component.groupForm.get('groupName').setValue('test-group');
+      component.groupForm.get('pool').setValue('rbd');
+      component.onSubmit();
+
+      expect(cephServiceService.create).not.toHaveBeenCalled();
+    });
+
+    it('should create service with correct spec', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [{ hostname: 'host1' }, { hostname: 'host2' }],
+        getSelectedHostnames: (): string[] => ['host1', 'host2']
+      } as any;
+
+      component.groupForm.get('groupName').setValue('defalut');
+      component.groupForm.get('pool').setValue('rbd');
+      component.groupForm.get('unmanaged').setValue(false);
+      component.onSubmit();
+
+      expect(cephServiceService.create).toHaveBeenCalledWith({
+        service_type: 'nvmeof',
+        service_id: 'rbd.defalut',
+        pool: 'rbd',
+        group: 'defalut',
+        placement: {
+          hosts: ['host1', 'host2']
+        },
+        unmanaged: false
+      });
+    });
+
+    it('should create service with unmanaged flag set to true', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+        getSelectedHostnames: (): string[] => ['host1']
+      } as any;
+
+      component.groupForm.get('groupName').setValue('unmanaged-group');
+      component.groupForm.get('pool').setValue('rbd');
+      component.groupForm.get('unmanaged').setValue(true);
+      component.onSubmit();
+
+      expect(cephServiceService.create).toHaveBeenCalledWith(
+        jasmine.objectContaining({
+          unmanaged: true,
+          group: 'unmanaged-group',
+          pool: 'rbd'
+        })
+      );
+    });
+
+    it('should navigate to list view on success', () => {
+      component.gatewayNodeComponent = {
+        getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+        getSelectedHostnames: (): string[] => ['host1']
+      } as any;
+
+      component.groupForm.get('groupName').setValue('test-group');
+      component.groupForm.get('pool').setValue('rbd');
+      component.onSubmit();
+
+      expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts
new file mode 100644 (file)
index 0000000..f91d156
--- /dev/null
@@ -0,0 +1,180 @@
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { Pool } from '../../pool/pool';
+import { NvmeofGatewayNodeComponent } from '../nvmeof-gateway-node/nvmeof-gateway-node.component';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Router } from '@angular/router';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+@Component({
+  selector: 'cd-nvmeof-group-form',
+  templateUrl: './nvmeof-group-form.component.html',
+  styleUrls: ['./nvmeof-group-form.component.scss'],
+  standalone: false
+})
+export class NvmeofGroupFormComponent extends CdForm implements OnInit {
+  @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
+
+  permission: Permission;
+  groupForm: CdFormGroup;
+  action: string;
+  resource: string;
+  group: string;
+  pools: Pool[] = [];
+  poolsLoading = false;
+  pageURL: string;
+  hasAvailableNodes = true;
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    public actionLabels: ActionLabelsI18n,
+    private poolService: PoolService,
+    private taskWrapperService: TaskWrapperService,
+    private cephServiceService: CephServiceService,
+    private nvmeofService: NvmeofService,
+    private router: Router
+  ) {
+    super();
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+    this.resource = $localize`gateway group`;
+  }
+
+  ngOnInit() {
+    this.action = this.actionLabels.CREATE;
+    this.createForm();
+    this.loadPools();
+  }
+
+  createForm() {
+    this.groupForm = new CdFormGroup({
+      groupName: new UntypedFormControl(
+        null,
+        [
+          Validators.required,
+          (control) => {
+            const value = control.value;
+            return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
+          }
+        ],
+        [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
+      ),
+      pool: new UntypedFormControl('rbd', {
+        validators: [Validators.required]
+      }),
+      unmanaged: new UntypedFormControl(false)
+    });
+  }
+
+  onHostsLoaded(count: number): void {
+    this.hasAvailableNodes = count > 0;
+  }
+
+  get isCreateDisabled(): boolean {
+    if (!this.hasAvailableNodes) {
+      return true;
+    }
+    if (!this.groupForm) {
+      return true;
+    }
+    if (this.groupForm.pending) {
+      return true;
+    }
+    if (this.groupForm.invalid) {
+      return true;
+    }
+    const errors = this.groupForm.errors as { [key: string]: any } | null;
+    if (errors && errors.cdSubmitButton) {
+      return true;
+    }
+    if (this.gatewayNodeComponent) {
+      const selected = this.gatewayNodeComponent.getSelectedHostnames?.() || [];
+      if (selected.length === 0) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  loadPools() {
+    this.poolsLoading = true;
+    this.poolService.list().then(
+      (pools: Pool[]) => {
+        this.pools = (pools || []).filter(
+          (pool: Pool) => pool.application_metadata && pool.application_metadata.includes('rbd')
+        );
+        this.poolsLoading = false;
+        if (this.pools.length >= 1) {
+          const allPoolNames = this.pools.map((pool) => pool.pool_name);
+          const poolName = allPoolNames.includes('rbd') ? 'rbd' : this.pools[0].pool_name;
+          this.groupForm.patchValue({ pool: poolName });
+        }
+      },
+      () => {
+        this.pools = [];
+        this.poolsLoading = false;
+      }
+    );
+  }
+
+  onSubmit() {
+    if (this.groupForm.invalid) {
+      return;
+    }
+
+    if (this.groupForm.pending) {
+      this.groupForm.setErrors({ cdSubmitButton: true });
+      return;
+    }
+
+    const formValues = this.groupForm.value;
+    const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
+    if (selectedHostnames.length === 0) {
+      this.groupForm.setErrors({ cdSubmitButton: true });
+      return;
+    }
+    let taskUrl = `service/${URLVerbs.CREATE}`;
+    const serviceName = `${formValues.pool}.${formValues.groupName}`;
+
+    const serviceSpec = {
+      service_type: 'nvmeof',
+      service_id: serviceName,
+      pool: formValues.pool,
+      group: formValues.groupName,
+      placement: {
+        hosts: selectedHostnames
+      },
+      unmanaged: formValues.unmanaged
+    };
+
+    this.taskWrapperService
+      .wrapTaskAroundCall({
+        task: new FinishedTask(taskUrl, {
+          service_name: `nvmeof.${serviceName}`
+        }),
+        call: this.cephServiceService.create(serviceSpec)
+      })
+      .subscribe({
+        complete: () => {
+          this.goToListView();
+        },
+        error: () => {
+          this.groupForm.setErrors({ cdSubmitButton: true });
+        }
+      });
+  }
+
+  private goToListView() {
+    this.router.navigateByUrl('/block/nvmeof/gateways');
+  }
+}
index c9956b82e8ccea17d067975f4bc23c62c79066ff..4a794e11a9f6e075cf5a89199b6ae540f80046c7 100644 (file)
@@ -1,6 +1,6 @@
 <div class="pb-3"
      cdsCol
-     [columnNumbers]="{md: 4}">
+     [columnNumbers]="{md: 6}">
   <cds-combo-box
       type="single"
       label="Selected Gateway Group"
index 51d8235f5329ed2523c867c3a0908f890a08d492..306dca8d5472529d6387ca2eef4871ae5bc60f25 100644 (file)
@@ -14,6 +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';
 
 @Injectable({
   providedIn: 'root'
@@ -168,4 +169,12 @@ export class HostService extends ApiClient {
   getAllHosts(): Observable<Host[]> {
     return this.http.get<Host[]>(`${this.baseUIURL}/list`);
   }
+
+  checkHostsFactsAvailable(orchStatus?: OrchestratorStatus): boolean {
+    const orchFeatures = orchStatus?.features;
+    if (!_.isEmpty(orchFeatures)) {
+      return !!orchFeatures.get_facts?.available;
+    }
+    return false;
+  }
 }
index ccecea5f0205db9aad076796b4a767e57465ed67..17d6e83548d389c7c4c75f238e349d39eba1e195 100755 (executable)
@@ -41,6 +41,58 @@ describe('NvmeofService', () => {
       const req = httpTesting.expectOne(`${API_PATH}/gateway`);
       expect(req.request.method).toBe('GET');
     });
+
+    it('should check if gateway group exists - returns true when group exists', () => {
+      const mockGroups = [
+        [
+          {
+            spec: { group: 'default' },
+            service_name: 'nvmeof.rbd.default'
+          },
+          {
+            spec: { group: 'test-group' },
+            service_name: 'nvmeof.rbd.test-group'
+          }
+        ]
+      ];
+
+      service.exists('default').subscribe((exists) => {
+        expect(exists).toBe(true);
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      expect(req.request.method).toBe('GET');
+      req.flush(mockGroups);
+    });
+
+    it('should check if gateway group exists - returns false when group does not exist', () => {
+      const mockGroups = [
+        [
+          {
+            spec: { group: 'default' },
+            service_name: 'nvmeof.rbd.default'
+          }
+        ]
+      ];
+
+      service.exists('non-existent-group').subscribe((exists) => {
+        expect(exists).toBe(false);
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      expect(req.request.method).toBe('GET');
+      req.flush(mockGroups);
+    });
+
+    it('should check if gateway group exists - returns false on API error', () => {
+      service.exists('test-group').subscribe((exists) => {
+        expect(exists).toBe(false);
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      expect(req.request.method).toBe('GET');
+      req.error(new ErrorEvent('Network error'));
+    });
   });
 
   describe('test subsystems APIs', () => {
index 0fd598e5f53b189dafc7526185ec4c8d6e24bdbf..7dfc417275d1fd33ac8a32bede3e47be69b17d35 100644 (file)
@@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http';
 
 import _ from 'lodash';
 import { Observable, of as observableOf } from 'rxjs';
-import { catchError, mapTo } from 'rxjs/operators';
+import { catchError, map, mapTo } from 'rxjs/operators';
 import { CephServiceSpec } from '../models/service.interface';
 
 export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512;
@@ -198,4 +198,20 @@ export class NvmeofService {
       }
     );
   }
+
+  // Check if gateway group exists
+  exists(groupName: string): Observable<boolean> {
+    return this.listGatewayGroups().pipe(
+      map((groups: CephServiceSpec[][]) => {
+        const groupsList = groups?.[0] ?? [];
+        return groupsList.some((group: CephServiceSpec) => group?.spec?.group === groupName);
+      }),
+      catchError((error: any) => {
+        if (_.isFunction(error?.preventDefault)) {
+          error.preventDefault();
+        }
+        return observableOf(false);
+      })
+    );
+  }
 }
index c487d8cc291efdff0a7419d3b8c3208124f91463..08cc137916b937cd6c8ac3bdb9583fd1a48020bd 100644 (file)
@@ -43,7 +43,8 @@ export enum URLVerbs {
 
   /* Multi-cluster */
   CONNECT = 'connect',
-  RECONNECT = 'reconnect'
+  RECONNECT = 'reconnect',
+  GATEWAY_GROUP = 'Gateway group'
 }
 
 export enum ActionLabels {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/host-status.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/host-status.enum.ts
new file mode 100644 (file)
index 0000000..89e2642
--- /dev/null
@@ -0,0 +1,5 @@
+export enum HostStatus {
+  AVAILABLE = 'Available',
+  MAINTENANCE = 'Maintenance',
+  RUNNING = 'Running'
+}