]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: add-gateway-nodes
authorSagar Gopale <sagar.gopale@ibm.com>
Mon, 19 Jan 2026 11:42:23 +0000 (17:12 +0530)
committerSagar Gopale <sagar.gopale@ibm.com>
Thu, 12 Feb 2026 06:03:41 +0000 (11:33 +0530)
Fixes: https://tracker.ceph.com/issues/74335
Signed-off-by: Sagar Gopale <sagar.gopale@ibm.com>
:wq

12 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-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts [new file with mode: 0644]
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/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/models/service.interface.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index bc3a68c36be3b6d7dfdc349bdd5d467a67b1d50d..9d90cba86369cd8e32f7efa77ef890118b6bf589 100644 (file)
@@ -75,7 +75,8 @@ import {
   LayoutModule,
   ContainedListModule,
   LayerModule,
-  ThemeModule
+  ThemeModule,
+  LayoutModule
 } from 'carbon-components-angular';
 
 // Icons
@@ -91,6 +92,7 @@ import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvme
 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';
+import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
 
 @NgModule({
   imports: [
@@ -124,7 +126,8 @@ import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
     LayoutModule,
     ContainedListModule,
     SideNavModule,
-    ThemeModule
+    ThemeModule,
+    LayoutModule
   ],
   declarations: [
     RbdListComponent,
@@ -168,7 +171,8 @@ import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
     NvmeofSubsystemsStepTwoComponent,
     NvmeofSubsystemsStepThreeComponent,
     NvmeGatewayViewComponent,
-    NvmeofGatewaySubsystemComponent
+    NvmeofGatewaySubsystemComponent,
+    NvmeofGatewayNodeAddModalComponent
   ],
 
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.html
new file mode 100644 (file)
index 0000000..7e88c89
--- /dev/null
@@ -0,0 +1,90 @@
+<cds-modal
+  [open]="open"
+  (overlaySelected)="closeModal()"
+  [size]="'lg'">
+  <cds-modal-header
+    (closeSelect)="closeModal()">
+    <h4
+      class="cds--type-heading-04 cds-ml-3"
+      i18n>
+      Add gateway nodes
+    </h4>
+    <p
+      class="cds--type-body-compact-01 cds-ml-3"
+      i18n>
+      Select NVMe-oF gateway nodes to associate with this gateway group.
+    </p>
+  </cds-modal-header>
+  <section cdsModalContent>
+
+    <div class="cds-mt-5">
+      <div
+        cdsStack="vertical"
+        gap="1">
+        <div
+          class="cds--type-heading-01"
+          i18n>
+          Select gateway nodes
+        </div>
+        <div
+          class="cds--type-label-01"
+          i18n>
+          Nodes to run NVMe-oF target pods/services
+        </div>
+      </div>
+
+      <cd-table
+        (fetchData)="getHosts($event)"
+        selectionType="multiClick"
+        [searchableObjects]="true"
+        [data]="hosts"
+        [columns]="columns"
+        (updateSelection)="updateSelection($event)"
+        [autoReload]="false">
+      </cd-table>
+
+      <ng-template
+        #addrTemplate
+        let-value="data.value">
+        <span>{{ value || '-' }}</span>
+      </ng-template>
+
+      <ng-template
+        #statusTemplate
+        let-value="data.value"
+        let-row="data.row">
+        <div
+          class="status-cell"
+          cdsStack="horizontal"
+          gap="3">
+        @if (value === HostStatus.AVAILABLE) {
+          <cd-icon type="success"></cd-icon>
+        }
+        <span>{{ value | titlecase }}</span>
+        </div>
+      </ng-template>
+
+      <ng-template
+        #labelsTemplate
+        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>
+
+    </div>
+  </section>
+  <cd-form-button-panel
+    [modalForm]="true"
+    [showSubmit]="true"
+    submitText="Add"
+    i18n-submitText
+    [disabled]="selection.selected.length === 0"
+    (submitActionEvent)="onSubmit()"
+    (backActionEvent)="closeModal()">
+  </cd-form-button-panel>
+</cds-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.scss
new file mode 100644 (file)
index 0000000..dbb535a
--- /dev/null
@@ -0,0 +1,7 @@
+cds-modal-header {
+  border-bottom: 1px solid var(--cds-ui-03, #e0e0e0);
+}
+
+.status-cell {
+  align-items: center;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.spec.ts
new file mode 100644 (file)
index 0000000..f154d76
--- /dev/null
@@ -0,0 +1,99 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node-add-modal.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RouterTestingModule } from '@angular/router/testing';
+import { HostService } from '~/app/shared/api/host.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TaskMessageService } from '~/app/shared/services/task-message.service';
+import { of } from 'rxjs';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+
+describe('NvmeofGatewayNodeAddModalComponent', () => {
+  let component: NvmeofGatewayNodeAddModalComponent;
+  let fixture: ComponentFixture<NvmeofGatewayNodeAddModalComponent>;
+
+  const mockHostService = {
+    checkHostsFactsAvailable: jest.fn().mockReturnValue(true),
+    list: jest.fn().mockReturnValue(of([{ hostname: 'host1' }, { hostname: 'host2' }]))
+  };
+
+  const mockNvmeofService = {
+    getAvailableHosts: jest.fn().mockReturnValue(of([{ hostname: 'host2' }]))
+  };
+
+  const mockCephServiceService = {
+    update: jest.fn().mockReturnValue(of({}))
+  };
+
+  const mockNotificationService = {
+    show: jest.fn()
+  };
+
+  const mockTaskMessageService = {
+    messages: {
+      'nvmeof/gateway/node/add': {
+        success: jest.fn().mockReturnValue('Success'),
+        failure: jest.fn().mockReturnValue('Failure')
+      }
+    }
+  };
+
+  const mockServiceSpec = {
+    placement: {
+      hosts: ['host1']
+    }
+  };
+
+  configureTestBed({
+    imports: [SharedModule, HttpClientTestingModule, RouterTestingModule],
+    declarations: [NvmeofGatewayNodeAddModalComponent],
+    providers: [
+      { provide: HostService, useValue: mockHostService },
+      { provide: NvmeofService, useValue: mockNvmeofService },
+      { provide: CephServiceService, useValue: mockCephServiceService },
+      { provide: NotificationService, useValue: mockNotificationService },
+      { provide: TaskMessageService, useValue: mockTaskMessageService },
+      { provide: 'groupName', useValue: 'group1' },
+      { provide: 'usedHostnames', useValue: ['host1'] },
+      { provide: 'serviceSpec', useValue: mockServiceSpec }
+    ]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(NvmeofGatewayNodeAddModalComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should load attributes', () => {
+    expect(component.groupName).toBe('group1');
+    expect(component.usedHostnames).toEqual(['host1']);
+    expect(component.serviceSpec).toEqual(mockServiceSpec);
+  });
+
+  it('should load hosts using NvmeofService', () => {
+    const context = new CdTableFetchDataContext(() => undefined);
+    component.getHosts(context);
+
+    expect(mockNvmeofService.getAvailableHosts).toHaveBeenCalled();
+    expect(component.hosts.length).toBe(1);
+    expect(component.hosts[0].hostname).toBe('host2');
+  });
+
+  it('should add gateway node', () => {
+    component.selection.selected = [{ hostname: 'host2' }];
+    component.onSubmit();
+
+    expect(mockCephServiceService.update).toHaveBeenCalledWith({
+      placement: { hosts: ['host1', 'host2'] }
+    });
+    expect(mockNotificationService.show).toHaveBeenCalled();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component.ts
new file mode 100644 (file)
index 0000000..93d856d
--- /dev/null
@@ -0,0 +1,185 @@
+import {
+  Component,
+  EventEmitter,
+  OnInit,
+  ViewChild,
+  TemplateRef,
+  OnDestroy,
+  Inject
+} from '@angular/core';
+import { Subscription } from 'rxjs';
+
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Host } from '~/app/shared/models/host.interface';
+import { HostStatus } from '~/app/shared/enum/host-status.enum';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CephServiceSpec, CephServiceSpecUpdate } from '~/app/shared/models/service.interface';
+import { TaskMessageService } from '~/app/shared/services/task-message.service';
+
+@Component({
+  selector: 'cd-nvmeof-gateway-node-add-modal',
+  templateUrl: './nvmeof-gateway-node-add-modal.component.html',
+  styleUrls: ['./nvmeof-gateway-node-add-modal.component.scss'],
+  standalone: false
+})
+export class NvmeofGatewayNodeAddModalComponent extends CdForm implements OnInit, OnDestroy {
+  hosts: Host[] = [];
+  columns: CdTableColumn[] = [];
+  selection = new CdTableSelection();
+  public gatewayAdded = new EventEmitter<void>();
+  isLoadingHosts = false;
+  private tableContext: CdTableFetchDataContext = null;
+  private sub = new Subscription();
+  private readonly ADD_GATEWAY_NODE_TASK = 'nvmeof/gateway/node/add';
+
+  @ViewChild('statusTemplate', { static: true })
+  statusTemplate!: TemplateRef<any>;
+
+  @ViewChild('labelsTemplate', { static: true })
+  labelsTemplate!: TemplateRef<any>;
+
+  @ViewChild('addrTemplate', { static: true })
+  addrTemplate!: TemplateRef<any>;
+
+  HostStatus = HostStatus;
+
+  constructor(
+    private nvmeofService: NvmeofService,
+    private cephServiceService: CephServiceService,
+    private notificationService: NotificationService,
+    private taskMessageService: TaskMessageService,
+    @Inject('groupName') public groupName: string,
+    @Inject('usedHostnames') public usedHostnames: string[],
+    @Inject('serviceSpec') public serviceSpec: CephServiceSpec
+  ) {
+    super();
+  }
+
+  ngOnInit(): void {
+    this.columns = [
+      {
+        name: $localize`Hostname`,
+        prop: 'hostname',
+        flexGrow: 2
+      },
+      {
+        name: $localize`IP address`,
+        prop: 'addr',
+        flexGrow: 2,
+        cellTemplate: this.addrTemplate
+      },
+      {
+        name: $localize`Status`,
+        prop: 'status',
+        flexGrow: 1,
+        cellTemplate: this.statusTemplate
+      },
+      {
+        name: $localize`Labels (tags)`,
+        prop: 'labels',
+        flexGrow: 3,
+        cellTemplate: this.labelsTemplate
+      }
+    ];
+  }
+
+  ngOnDestroy(): void {
+    this.sub.unsubscribe();
+  }
+
+  getHosts(context: CdTableFetchDataContext) {
+    if (context !== null) {
+      this.tableContext = context;
+    }
+    if (this.tableContext == null) {
+      this.tableContext = new CdTableFetchDataContext(() => undefined);
+    }
+    if (this.isLoadingHosts) {
+      return;
+    }
+    this.isLoadingHosts = true;
+
+    this.sub.add(
+      this.nvmeofService.getAvailableHosts(this.tableContext?.toParams()).subscribe({
+        next: (hostList: Host[]) => {
+          this.hosts = hostList;
+          this.isLoadingHosts = false;
+        },
+        error: () => {
+          this.isLoadingHosts = false;
+          context.error();
+        }
+      })
+    );
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  onSubmit() {
+    if (!this.serviceSpec) {
+      this.notificationService.show(
+        NotificationType.error,
+        $localize`Service specification is missing.`
+      );
+      return;
+    }
+
+    this.loadingStart();
+
+    const modifiedSpec = this.createServiceSpecPayload();
+
+    this.cephServiceService.update(modifiedSpec).subscribe({
+      next: () => {
+        this.notificationService.show(
+          NotificationType.success,
+          this.taskMessageService.messages[this.ADD_GATEWAY_NODE_TASK].success({
+            group_name: this.groupName
+          })
+        );
+        this.gatewayAdded.emit();
+        this.loadingReady();
+        this.closeModal();
+      },
+      error: (e) => {
+        this.loadingReady();
+        this.notificationService.show(
+          NotificationType.error,
+          this.taskMessageService.messages[this.ADD_GATEWAY_NODE_TASK].failure({
+            group_name: this.groupName
+          }),
+          e
+        );
+      }
+    });
+  }
+
+  private createServiceSpecPayload(): CephServiceSpecUpdate {
+    const selectedHosts = this.selection.selected.map((h: Host) => h.hostname);
+    const currentHosts = this.serviceSpec.placement?.hosts || [];
+    const newHosts = [...currentHosts, ...selectedHosts];
+
+    const { status, ...modifiedSpec } = this.serviceSpec;
+
+    if ('events' in modifiedSpec) {
+      delete (modifiedSpec as any).events;
+    }
+
+    if (modifiedSpec.placement) {
+      modifiedSpec.placement = { ...modifiedSpec.placement };
+    } else {
+      modifiedSpec.placement = {};
+    }
+
+    modifiedSpec.placement.hosts = newHosts;
+
+    return modifiedSpec;
+  }
+}
index bdbde0054067486a20a718bea43d99d74906a5ef..77fc7e619fc94bf6718395cdda8e292742192440 100644 (file)
     [maxLimit]="25"
     identifier="hostname"
     forceIdentifier="true"
+    [autoReload]="false"
     (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-actions
       class="table-actions"
   </cd-table>
 </div>
 
+<ng-template
+  #hostNameTpl
+  let-value="data.value"
+>
+  <span class="cds-ml-2">{{ value }}</span>
+</ng-template>
+
 <ng-template
   #addrTpl
   let-value="data.value"
 >
   <span>{{ value || '-' }}</span>
-
 </ng-template>
 
 <ng-template
     [cdsStack]="'horizontal'"
     gap="4"
   >
-  @if (value === HostStatus.AVAILABLE) {
+  @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) {
     <cd-icon type="success"></cd-icon>
+  } @else {
+    <cd-icon type="error"></cd-icon>
   }
   <span class="cds-ml-3">{{ value | titlecase }}</span>
   </div>
 }
 </ng-template>
 
-<ng-template #labelsTpl
-             let-value="data.value">
+<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>
index 576b2e19ad654dd6091e58281f0a5038f648a96d..3603b2c15c192ac08de87599beecf4cfba4e762c 100644 (file)
@@ -104,9 +104,11 @@ describe('NvmeofGatewayNodeComponent', () => {
         provide: ActivatedRoute,
         useValue: {
           parent: {
-            params: new BehaviorSubject({ group: 'group1' })
+            params: new BehaviorSubject({ group: 'group1' }),
+            snapshot: {
+              params: { group: 'group1' }
+            }
           },
-          data: of({ mode: 'selector' }),
           snapshot: {
             data: { mode: 'selector' }
           }
@@ -149,7 +151,7 @@ describe('NvmeofGatewayNodeComponent', () => {
   it('should initialize with default values', () => {
     expect(component.hosts).toEqual([]);
     expect(component.isLoadingHosts).toBe(false);
-    expect(component.totalHostCount).toBe(5);
+    expect(component.count).toBe(0);
     expect(component.permission).toBeDefined();
   });
 
@@ -184,119 +186,34 @@ describe('NvmeofGatewayNodeComponent', () => {
   });
 
   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,
-      features: new Map([['get_facts', { available: true }]])
-    };
-
-    spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    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();
-
+    spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of([mockGatewayNodes[2]]));
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
     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');
+    expect(nvmeofService.getAvailableHosts).toHaveBeenCalled();
+    expect(component.hosts.length).toBe(1);
+    expect(component.hosts[0]['hostname']).toBe('gateway-node-3');
   }));
 
   it('should set count to hosts length', fakeAsync(() => {
-    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([
-        [
-          {
-            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();
-
+    spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of(mockGatewayNodes));
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
     tick(100);
-    // Count should equal the filtered hosts length
-    expect(component.totalHostCount).toBe(component.hosts.length);
+    expect(component.count).toBe(component.hosts.length);
   }));
 
   it('should set count to 0 when no hosts are returned', fakeAsync(() => {
-    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([
-        [
-          {
-            service_id: 'nvmeof.group1',
-            placement: { hosts: ['gateway-node-1'] }
-          }
-        ]
-      ] as any)
-    );
-    spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
-    component.groupName = 'group1';
-    fixture.detectChanges();
-
+    spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of([]));
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
     tick(100);
-    expect(component.totalHostCount).toBe(0);
+    expect(component.count).toBe(0);
     expect(component.hosts.length).toBe(0);
   }));
 
   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 = {
-      available: true,
-      features: new Map()
-    };
-
-    spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
-    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();
-
+    spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(throwError(() => new Error('Error')));
     const context = new CdTableFetchDataContext(() => undefined);
     spyOn(context, 'error');
 
@@ -309,12 +226,12 @@ describe('NvmeofGatewayNodeComponent', () => {
 
   it('should not re-fetch if already loading', fakeAsync(() => {
     component.isLoadingHosts = true;
-    const hostListSpy = spyOn(hostService, 'list');
+    spyOn(nvmeofService, 'getAvailableHosts');
 
     component.getHosts(new CdTableFetchDataContext(() => undefined));
 
     tick(100);
-    expect(hostListSpy).not.toHaveBeenCalled();
+    expect(nvmeofService.getAvailableHosts).not.toHaveBeenCalled();
   }));
 
   it('should unsubscribe on component destroy', fakeAsync(() => {
@@ -461,7 +378,7 @@ describe('NvmeofGatewayNodeComponent', () => {
   }));
 
   it('should fetch data using fetchHostsAndGroups in details mode', fakeAsync(() => {
-    (component as any).route.data = of({ mode: 'details' });
+    (component as any).route.snapshot.data = { mode: 'details' };
     component.ngOnInit();
     component.groupName = 'group1';
 
@@ -487,17 +404,16 @@ describe('NvmeofGatewayNodeComponent', () => {
     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 as any).route.snapshot.data = { 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 as any).route.snapshot.data = { mode: 'details' };
     component.ngOnInit();
     expect(component.selectionType).toBe('single');
   });
index ae777677f541313db5e2a052d1496dce2f4e0159..42f50767e755154a76c192929417cbe222c3b32d 100644 (file)
@@ -9,16 +9,12 @@ import {
   ViewChild
 } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
-import { forkJoin, Subject, Subscription } from 'rxjs';
-import { finalize, mergeMap } from 'rxjs/operators';
+import { Observable, Subject, Subscription } from 'rxjs';
+import { finalize } 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';
@@ -30,6 +26,8 @@ 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 { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
 
 @Component({
   selector: 'cd-nvmeof-gateway-node',
@@ -55,11 +53,13 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
 
   @Output() selectionChange = new EventEmitter<CdTableSelection>();
   @Output() hostsLoaded = new EventEmitter<number>();
+
   @Input() groupName: string | undefined;
-  @Input() mode: NvmeofGatewayNodeMode = NvmeofGatewayNodeMode.SELECTOR;
+  @Input() mode: 'selector' | 'details' = 'selector';
 
   usedHostnames: Set<string> = new Set();
   serviceSpec: CephServiceSpec | undefined;
+  hasAvailableHosts = false;
 
   permission: Permission;
   columns: CdTableColumn[] = [];
@@ -72,50 +72,33 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
   icons = Icons;
   HostStatus = HostStatus;
   private tableContext: CdTableFetchDataContext | undefined;
-  totalHostCount = 5;
+  count = 0;
   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 route: ActivatedRoute
+    private route: ActivatedRoute,
+    private modalService: ModalCdsService
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
   }
 
   ngOnInit(): void {
-    this.route.data.subscribe((data) => {
-      if (data?.['mode']) {
-        this.mode = data['mode'];
-      }
-    });
+    const routeData = this.route.snapshot.data;
+    if (routeData?.['mode']) {
+      this.mode = routeData['mode'];
+    }
 
-    this.selectionType = this.mode === NvmeofGatewayNodeMode.SELECTOR ? 'multiClick' : 'single';
+    this.selectionType = this.mode === 'selector' ? 'multiClick' : 'single';
 
-    if (this.mode === NvmeofGatewayNodeMode.DETAILS) {
-      this.route.parent?.params.subscribe((params: { group: string }) => {
+    if (this.mode === 'details') {
+      this.route.parent?.params.subscribe((params: any) => {
         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.setTableActions();
     }
 
     this.columns = [
@@ -146,8 +129,36 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
     ];
   }
 
+  private setTableActions() {
+    this.tableActions = [
+      {
+        permission: 'create',
+        icon: Icons.add,
+        click: () => this.addGateway(),
+        name: $localize`Add`,
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+        disable: () => (!this.hasAvailableHosts ? $localize`No available nodes to add` : false)
+      },
+      {
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.removeGateway(),
+        name: $localize`Remove`,
+        disable: (selection: CdTableSelection) => !selection.hasSelection
+      }
+    ];
+  }
+
   addGateway(): void {
-    // TODO
+    const modalRef = this.modalService.show(NvmeofGatewayNodeAddModalComponent, {
+      groupName: this.groupName,
+      usedHostnames: Array.from(this.usedHostnames),
+      serviceSpec: this.serviceSpec
+    });
+
+    modalRef.gatewayAdded.subscribe(() => {
+      this.table.refreshBtn();
+    });
   }
 
   removeGateway(): void {
@@ -183,22 +194,10 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
       this.sub.unsubscribe();
     }
 
-    const fetchData$ =
-      this.mode === NvmeofGatewayNodeMode.DETAILS
+    const fetchData$: Observable<any> =
+      this.mode === '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.nvmeofService.getAvailableHosts(this.tableContext?.toParams());
 
     this.sub = fetchData$
       .pipe(
@@ -208,47 +207,50 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
       )
       .subscribe({
         next: (result: any) => {
-          this.mode === NvmeofGatewayNodeMode.DETAILS
-            ? this.processHostsForDetailsMode(result.groups, result.hosts)
-            : this.processHostsForSelectorMode(result.groups, result.hosts);
+          if (this.mode === 'details') {
+            this.processDetailsData(result.groups, result.hosts);
+          } else {
+            this.hosts = result;
+            this.count = this.hosts.length;
+            this.hostsLoaded.emit(this.count);
+          }
         },
         error: () => context?.error()
       });
   }
 
-  /**
-   * 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));
+  private processDetailsData(groups: any[][], hostList: Host[]) {
+    const groupList = groups?.[0] ?? [];
+
+    const allUsedHostnames = new Set<string>();
+    groupList.forEach((group: CephServiceSpec) => {
+      const hosts = group.placement?.hosts || (group.spec as any)?.placement?.hosts || [];
+      hosts.forEach((hostname: string) => allUsedHostnames.add(hostname));
     });
-    this.usedHostnames = usedHosts;
 
-    this.hosts = (hostList || []).filter((host: Host) => !this.usedHostnames.has(host.hostname));
+    this.usedHostnames = allUsedHostnames;
 
-    this.updateCount();
-  }
-
-  /**
-   * 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
+    // Check if there are any available hosts globally (not used by any group)
+    this.hasAvailableHosts = (hostList || []).some(
+      (host: Host) => !this.usedHostnames.has(host.hostname)
     );
+    this.setTableActions();
+
+    const currentGroup = groupList.find((group: CephServiceSpec) => {
+      return (
+        group.spec?.group === this.groupName ||
+        group.service_id === `nvmeof.${this.groupName}` ||
+        group.service_id.endsWith(`.${this.groupName}`)
+      );
+    });
 
-    if (!currentGroup) {
+    this.serviceSpec = currentGroup as CephServiceSpec;
+
+    if (!this.serviceSpec) {
       this.hosts = [];
     } else {
       const placementHosts =
-        currentGroup.placement?.hosts || (currentGroup.spec as any)?.placement?.hosts || [];
+        this.serviceSpec.placement?.hosts || (this.serviceSpec.spec as any)?.placement?.hosts || [];
       const currentGroupHosts = new Set<string>(placementHosts);
 
       this.hosts = (hostList || []).filter((host: Host) => {
@@ -256,12 +258,7 @@ export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
       });
     }
 
-    this.serviceSpec = currentGroup;
-    this.updateCount();
-  }
-
-  private updateCount(): void {
-    this.totalHostCount = this.hosts.length;
-    this.hostsLoaded.emit(this.totalHostCount);
+    this.count = this.hosts.length;
+    this.hostsLoaded.emit(this.count);
   }
 }
index 46f38d08038d454a63a86bb258462ccb28da2db4..36c6b752a8b7ce22dc766d24395a3ac7ac11c3c0 100755 (executable)
@@ -1,18 +1,34 @@
 import { TestBed } from '@angular/core/testing';
 import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
 import { configureTestBed } from '~/testing/unit-test-helper';
-import { NvmeofService } from '../../shared/api/nvmeof.service';
-import { throwError } from 'rxjs';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { HostService } from './host.service';
+import { OrchestratorService } from './orchestrator.service';
+import { of, throwError } from 'rxjs';
 
 describe('NvmeofService', () => {
   let service: NvmeofService;
   let httpTesting: HttpTestingController;
   const mockGroupName = 'default';
   const mockNQN = 'nqn.2001-07.com.ceph:1721041732363';
+  const mockHostService = {
+    checkHostsFactsAvailable: jest.fn(),
+    list: jest.fn(),
+    getAllHosts: jest.fn()
+  };
+
+  const mockOrchService = {
+    status: jest.fn()
+  };
   const UI_API_PATH = 'ui-api/nvmeof';
   const API_PATH = 'api/nvmeof';
+
   configureTestBed({
-    providers: [NvmeofService],
+    providers: [
+      NvmeofService,
+      { provide: HostService, useValue: mockHostService },
+      { provide: OrchestratorService, useValue: mockOrchService }
+    ],
     imports: [HttpClientTestingModule]
   });
 
@@ -56,7 +72,7 @@ describe('NvmeofService', () => {
         ]
       ];
 
-      service.exists('default').subscribe((exists) => {
+      service.exists('default').subscribe((exists: boolean) => {
         expect(exists).toBe(true);
       });
 
@@ -75,7 +91,7 @@ describe('NvmeofService', () => {
         ]
       ];
 
-      service.exists('non-existent-group').subscribe((exists) => {
+      service.exists('non-existent-group').subscribe((exists: boolean) => {
         expect(exists).toBe(false);
       });
 
@@ -85,7 +101,7 @@ describe('NvmeofService', () => {
     });
 
     it('should check if gateway group exists - returns false on API error', () => {
-      service.exists('test-group').subscribe((exists) => {
+      service.exists('test-group').subscribe((exists: boolean) => {
         expect(exists).toBe(false);
       });
 
@@ -93,6 +109,41 @@ describe('NvmeofService', () => {
       expect(req.request.method).toBe('GET');
       req.error(new ErrorEvent('Network error'));
     });
+
+    it('should get available hosts', () => {
+      const mockGroups = [[{ placement: { hosts: ['used-host'] } }]];
+      const mockHosts = [
+        { hostname: 'used-host', status: 'Available' },
+        { hostname: 'free-host', status: 'Available' }
+      ];
+
+      mockOrchService.status.mockReturnValue(of({ available: true }));
+      mockHostService.checkHostsFactsAvailable.mockReturnValue(true);
+      mockHostService.list.mockReturnValue(of(mockHosts));
+
+      service.getAvailableHosts().subscribe((hosts: any[]) => {
+        expect(hosts.length).toBe(1);
+        expect(hosts[0].hostname).toBe('free-host');
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      req.flush(mockGroups);
+    });
+
+    it('should fetch hosts and groups', () => {
+      const mockGroups = [[{ spec: { group: 'group1' } }]];
+      const mockHosts = [{ hostname: 'host1', status: '' }];
+
+      mockHostService.getAllHosts.mockReturnValue(of(mockHosts));
+
+      service.fetchHostsAndGroups().subscribe((result: any) => {
+        expect(result.groups).toEqual(mockGroups);
+        expect(result.hosts[0].status).toBe('Available');
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      req.flush(mockGroups);
+    });
   });
 
   describe('test subsystems APIs', () => {
@@ -132,7 +183,7 @@ describe('NvmeofService', () => {
     });
     it('should call isSubsystemPresent', () => {
       spyOn(service, 'getSubsystem').and.returnValue(throwError('test'));
-      service.isSubsystemPresent(mockNQN, mockGroupName).subscribe((res) => {
+      service.isSubsystemPresent(mockNQN, mockGroupName).subscribe((res: boolean) => {
         expect(res).toBe(false);
       });
     });
index 5aecd4cb76f2cca4840535b6c312790bf03b83cb..8937eb5026b7da816a159b140151707efc51a224 100644 (file)
@@ -3,9 +3,13 @@ import { HttpClient } from '@angular/common/http';
 
 import _ from 'lodash';
 import { Observable, forkJoin, of as observableOf } from 'rxjs';
-import { catchError, map, mapTo } from 'rxjs/operators';
+import { catchError, map, mapTo, mergeMap } from 'rxjs/operators';
 import { CephServiceSpec } from '../models/service.interface';
 import { HostService } from './host.service';
+import { OrchestratorService } from './orchestrator.service';
+import { HostStatus } from '../enum/host-status.enum';
+import { Host } from '../models/host.interface';
+import { OrchestratorStatus } from '../models/orchestrator.interface';
 
 export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512;
 
@@ -49,12 +53,53 @@ const UI_API_PATH = 'ui-api/nvmeof';
   providedIn: 'root'
 })
 export class NvmeofService {
-  constructor(private http: HttpClient, private hostService: HostService) {}
+  constructor(
+    private http: HttpClient,
+    private hostService: HostService,
+    private orchService: OrchestratorService
+  ) {}
 
-  fetchHostsAndGroups() {
+  getAvailableHosts(params: any = {}): Observable<Host[]> {
     return forkJoin({
       groups: this.listGatewayGroups(),
-      hosts: this.hostService.getAllHosts()
+      hosts: this.orchService.status().pipe(
+        mergeMap((orchStatus: OrchestratorStatus) => {
+          const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus);
+          return this.hostService.list(params, factsAvailable.toString()) as Observable<Host[]>;
+        }),
+        map((hosts: Host[]) => {
+          return (hosts || []).map((host: Host) => ({
+            ...host,
+            status: host.status || HostStatus.AVAILABLE
+          }));
+        })
+      )
+    }).pipe(
+      map(({ groups, hosts }) => {
+        const usedHosts = new Set<string>();
+        (groups?.[0] ?? []).forEach((group: CephServiceSpec) => {
+          group.placement?.hosts?.forEach((hostname: string) => usedHosts.add(hostname));
+        });
+        return (hosts || []).filter((host: Host) => {
+          const isAvailable =
+            host.status === HostStatus.AVAILABLE || host.status === HostStatus.RUNNING;
+          return !usedHosts.has(host.hostname) && isAvailable;
+        });
+      })
+    );
+  }
+
+  fetchHostsAndGroups(): Observable<{ groups: CephServiceSpec[][]; hosts: Host[] }> {
+    return forkJoin({
+      groups: this.listGatewayGroups(),
+      hosts: this.hostService.getAllHosts().pipe(
+        map((hosts: Host[]) => {
+          return (hosts || []).map((host: Host) => ({
+            ...host,
+            status: host.status || HostStatus.AVAILABLE
+          }));
+        })
+      )
     });
   }
 
index 303590a814a742c675f3ec42002e00dd114a11e9..7fb34afd28f25b5cf9b702ba0d68c9a25024e527 100644 (file)
@@ -41,6 +41,9 @@ export interface CephServiceSpec {
   placement: CephServicePlacement;
 }
 
+// Type for service spec update payload (excludes read-only status field)
+export type CephServiceSpecUpdate = Omit<CephServiceSpec, 'status'>;
+
 export interface CephServiceAdditionalSpec {
   backend_service: string;
   api_user: string;
index 8a7bdc6d04ad9435a36d172a66fb103d13126d9a..48cc978d279b39f0c3cd43f8a80bfa9a53aeb0b1 100644 (file)
@@ -378,6 +378,9 @@ export class TaskMessageService {
     'nvmeof/gateway/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
       this.nvmeofGateway(metadata)
     ),
+    'nvmeof/gateway/node/add': this.newTaskMessage(this.commonOperations.add, (metadata) =>
+      this.nvmeofGatewayNode(metadata)
+    ),
     'nvmeof/subsystem/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.nvmeofSubsystem(metadata)
     ),
@@ -589,6 +592,10 @@ export class TaskMessageService {
   nvmeofGateway(metadata: any) {
     return $localize`Gateway group '${metadata.group}'`;
   }
+
+  nvmeofGatewayNode(metadata: any) {
+    return $localize`hosts to gateway group '${metadata.group_name}'`;
+  }
   nvmeofListener(metadata: any) {
     return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
   }