]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: NVmeof Add listeners in subsytem and resource page.
authorSagar Gopale <sagar.gopale@ibm.com>
Wed, 25 Feb 2026 16:34:13 +0000 (22:04 +0530)
committerSagar Gopale <sagar.gopale@ibm.com>
Thu, 26 Feb 2026 09:35:31 +0000 (15:05 +0530)
Fixes: https://tracker.ceph.com/issues/74339
Signed-off-by: Sagar Gopale <sagar.gopale@ibm.com>
26 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvme-subsystem-view/nvme-subsystem-view.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.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/components/tearsheet/tearsheet.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss
src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index 3b63c5a2731511d96a063d40c5e8746fa810155a..825c3b8b3f59b5f3183c86a50942ee2415008bc4 100644 (file)
@@ -448,6 +448,11 @@ const routes: Routes = [
             path: `${URLVerbs.ADD}/initiator`,
             component: NvmeofInitiatorsFormComponent,
             outlet: 'modal'
+          },
+          {
+            path: `${URLVerbs.ADD}/listener`,
+            component: NvmeofListenersFormComponent,
+            outlet: 'modal'
           }
         ]
       }
index 804a200341726e6c3b46629b20ba9ecbc108a128..1e1a444e4569caab407b6628583f881b1c421ff9 100644 (file)
@@ -8,7 +8,13 @@ import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumb
 })
 export class NvmeSubsystemViewBreadcrumbResolver extends BreadcrumbsResolver {
   resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
-    const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn;
-    return [{ text: decodeURIComponent(subsystemNQN || ''), path: null }];
+    const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn || '';
+    let decodedNQN = subsystemNQN;
+    try {
+      decodedNQN = decodeURIComponent(subsystemNQN);
+    } catch (e) {
+      // Fallback to raw value if decoding fails
+    }
+    return [{ text: decodedNQN, path: null }];
   }
 }
index f055b715a85b60ca9d8b0e5dbe57b7545993dda7..e7c3c196157c3ce672487575814d9b39fbf723a3 100644 (file)
@@ -1,7 +1,8 @@
-import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
 import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
 import { RouterTestingModule } from '@angular/router/testing';
-import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
 
 import { NvmeSubsystemViewComponent } from './nvme-subsystem-view.component';
 import { HttpClientTestingModule } from '@angular/common/http/testing';
@@ -10,11 +11,22 @@ describe('NvmeSubsystemViewComponent', () => {
   let component: NvmeSubsystemViewComponent;
   let fixture: ComponentFixture<NvmeSubsystemViewComponent>;
 
+  const mockParamMap = {
+    get: (key: string) => (key === 'subsystem_nqn' ? 'nqn.test' : null)
+  };
+  const mockQueryParams = { group: 'my-group' };
+
+  const mockActivatedRoute = {
+    paramMap: of(mockParamMap),
+    queryParams: of(mockQueryParams)
+  };
+
   beforeEach(
     waitForAsync(() => {
       TestBed.configureTestingModule({
         declarations: [NvmeSubsystemViewComponent],
-        imports: [RouterTestingModule, SideNavModule, ThemeModule, HttpClientTestingModule],
+        imports: [RouterTestingModule, HttpClientTestingModule],
+        providers: [{ provide: ActivatedRoute, useValue: mockActivatedRoute }],
         schemas: [CUSTOM_ELEMENTS_SCHEMA]
       }).compileComponents();
     })
@@ -29,4 +41,30 @@ describe('NvmeSubsystemViewComponent', () => {
   it('should create', () => {
     expect(component).toBeTruthy();
   });
+
+  it('should build sidebar items correctly', () => {
+    expect(component.sidebarItems.length).toBe(3);
+
+    // Verify first item (Initiators)
+    expect(component.sidebarItems[0].route).toEqual([
+      '/block/nvmeof/subsystems',
+      'nqn.test',
+      'hosts'
+    ]);
+    expect(component.sidebarItems[0].routeExtras).toEqual({ queryParams: { group: 'my-group' } });
+
+    // Verify second item (Namespaces)
+    expect(component.sidebarItems[1].route).toEqual([
+      '/block/nvmeof/subsystems',
+      'nqn.test',
+      'namespaces'
+    ]);
+
+    // Verify third item (Listeners)
+    expect(component.sidebarItems[2].route).toEqual([
+      '/block/nvmeof/subsystems',
+      'nqn.test',
+      'listeners'
+    ]);
+  });
 });
index 666e1f50d35251fe4a79849aa1d4b8d423a5606e..3876795c10f7a4fcc7778b32bb2c294d4e45b419 100644 (file)
@@ -80,9 +80,8 @@ export class NvmeofInitiatorsFormComponent implements OnInit {
         call: this.nvmeofService.addInitiators(this.subsystemNQN, request)
       })
       .subscribe({
-        error: (err) => {
+        error: () => {
           this.isSubmitLoading = false;
-          err.preventDefault();
         },
         complete: () => {
           this.isSubmitLoading = false;
index 2c2aff3cc9f90104f264b60e2b6ee24da9ae7566..ab2f0d7bfefcfe95f8b23a53c88bbd495e841055 100644 (file)
@@ -6,6 +6,7 @@ import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.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 { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import {
@@ -20,8 +21,6 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofEditHostKeyModalComponent } from '../nvmeof-edit-host-key-modal/nvmeof-edit-host-key-modal.component';
 
-const BASE_URL = 'block/nvmeof/subsystems';
-
 @Component({
   selector: 'cd-nvmeof-initiators-list',
   templateUrl: './nvmeof-initiators-list.component.html',
@@ -37,7 +36,7 @@ export class NvmeofInitiatorsListComponent implements OnInit {
   @ViewChild('dhchapTpl', { static: true })
   dhchapTpl: TemplateRef<any>;
 
-  initiatorColumns: any;
+  initiatorColumns: CdTableColumn[];
   tableActions: CdTableAction[];
   selection = new CdTableSelection();
   permission: Permission;
@@ -145,13 +144,6 @@ export class NvmeofInitiatorsListComponent implements OnInit {
     return this.initiators.some((initiator) => initiator.nqn === '*');
   }
 
-  editHostAccess() {
-    this.router.navigate(
-      [BASE_URL, { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }],
-      { queryParams: { group: this.group } }
-    );
-  }
-
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
index 910e5579ee4b4d558a53f16a23d3170609667bd1..c0775f727b31c920d3a137de9c1d32c4a92253ca 100644 (file)
@@ -1,76 +1,19 @@
-<cd-modal [pageURL]="pageURL">
-  <span class="modal-title"
-        i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
-  <ng-container class="modal-content">
-    <form name="listenerForm"
-          #formDir="ngForm"
-          [formGroup]="listenerForm"
-          novalidate>
-      <div class="modal-body">
-        <!-- Host -->
-        <div class="form-group row">
-          <label class="cd-col-form-label"
-                 for="host">
-            <span class="required"
-                  i18n>Hostname</span>
-          </label>
-          <div class="cd-col-form-input">
-            <select id="host"
-                    name="host"
-                    class="form-select"
-                    formControlName="host">
-              <option *ngIf="hosts === null"
-                      [ngValue]="null"
-                      i18n>Loading...</option>
-              <option *ngIf="hosts && hosts.length === 0"
-                      [ngValue]="null"
-                      i18n>-- No hosts available --</option>
-              <option *ngIf="hosts && hosts.length > 0"
-                      [ngValue]="null"
-                      i18n>-- Select a host --</option>
-              <option *ngFor="let hostsItem of hosts"
-                      [ngValue]="hostsItem">{{ hostsItem.hostname }}</option>
-            </select>
-            <cd-help-text i18n>
-                This hostname uniquely identifies the gateway on which the listener is being set up.
-            </cd-help-text>
-            <span class="invalid-feedback"
-                  *ngIf="listenerForm.showError('host', formDir, 'required')"
-                  i18n>This field is required.</span>
-          </div>
-        </div>
-        <!-- Transport Service ID -->
-        <div class="form-group row">
-          <label class="cd-col-form-label"
-                 for="trsvcid">
-            <span i18n>Transport Service ID</span>
-          </label>
-          <div class="cd-col-form-input">
-            <input id="trsvcid"
-                   class="form-control"
-                   type="text"
-                   name="trsvcid"
-                   formControlName="trsvcid">
-            <cd-help-text i18n>The IP port to use. Default is 4420.</cd-help-text>
-            <span class="invalid-feedback"
-                  *ngIf="listenerForm.showError('trsvcid', formDir, 'required')"
-                  i18n>This field is required.</span>
-            <span class="invalid-feedback"
-                  *ngIf="listenerForm.showError('trsvcid', formDir, 'max')"
-                  i18n>The value cannot be greated than 65535.</span>
-            <span class="invalid-feedback"
-                  *ngIf="listenerForm.showError('trsvcid', formDir, 'pattern')"
-                  i18n>The value must be a positive integer.</span>
-          </div>
-        </div>
-      </div>
-      <div class="modal-footer">
-        <div class="text-right">
-          <cd-form-button-panel (submitActionEvent)="onSubmit()"
-                                [form]="listenerForm"
-                                [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
-        </div>
-      </div>
-    </form>
-  </ng-container>
-</cd-modal>
+<cd-tearsheet
+  [steps]="steps"
+  [title]="title"
+  [description]="description"
+  size="sm"
+  (submitRequested)="onSubmit($event)"
+  [isSubmitLoading]="isSubmitLoading"
+  submitButtonLabel="Add"
+  i18n-submitButtonLabel>
+
+  <cd-tearsheet-step>
+    <cd-nvmeof-subsystem-step-one
+      #tearsheetStep
+               modal-primary-focus
+      [group]="group"
+      [subsystemNQN]="subsystemNQN"
+      [listenersOnly]="true"></cd-nvmeof-subsystem-step-one>
+  </cd-tearsheet-step>
+</cd-tearsheet>
index 74bad35b13cdb05d379e820f042c96d8c4bd841c..24b22fe8276e792a8b813e1e69257332b598376b 100644 (file)
@@ -1,13 +1,14 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ReactiveFormsModule } from '@angular/forms';
 import { RouterTestingModule } from '@angular/router/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
 
 import { ToastrModule } from 'ngx-toastr';
-import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
 
 import { SharedModule } from '~/app/shared/shared.module';
 import { NvmeofListenersFormComponent } from './nvmeof-listeners-form.component';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
 
 describe('NvmeofListenersFormComponent', () => {
   let component: NvmeofListenersFormComponent;
@@ -16,20 +17,26 @@ describe('NvmeofListenersFormComponent', () => {
   beforeEach(async () => {
     await TestBed.configureTestingModule({
       declarations: [NvmeofListenersFormComponent],
-      providers: [NgbActiveModal],
-      imports: [
-        HttpClientTestingModule,
-        NgbTypeaheadModule,
-        ReactiveFormsModule,
-        RouterTestingModule,
-        SharedModule,
-        ToastrModule.forRoot()
-      ]
+      providers: [
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            params: of({ subsystem_nqn: 'nqn.2001-07.com.ceph:1' }),
+            queryParams: of({ group: 'group1' }),
+            snapshot: { params: { subsystem_nqn: 'nqn.2001-07.com.ceph:1' } },
+            parent: {
+              snapshot: { params: { subsystem_nqn: 'nqn.2001-07.com.ceph:1' } },
+              params: of({ subsystem_nqn: 'nqn.2001-07.com.ceph:1' })
+            }
+          }
+        }
+      ],
+      imports: [HttpClientTestingModule, RouterTestingModule, SharedModule, ToastrModule.forRoot()],
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
     }).compileComponents();
 
     fixture = TestBed.createComponent(NvmeofListenersFormComponent);
     component = fixture.componentInstance;
-    component.ngOnInit();
     fixture.detectChanges();
   });
 
index 7bf74880c17439ed36e702efb12f5676a746a0fe..cb1f0293ee57c5710fb71b3670f035408f73d920 100644 (file)
@@ -1,22 +1,10 @@
-import _ from 'lodash';
 import { Component, OnInit } from '@angular/core';
-import { UntypedFormControl, Validators } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
-import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
-import { GatewayGroup, ListenerRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
-import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { Step } from 'carbon-components-angular';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { FinishedTask } from '~/app/shared/models/finished-task';
-import { Permission } from '~/app/shared/models/permissions';
-import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ListenerItem } from '~/app/shared/models/nvmeof';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
-import { FormatterService } from '~/app/shared/services/formatter.service';
-import { CdValidators } from '~/app/shared/forms/cd-validators';
-import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
-import { HostService } from '~/app/shared/api/host.service';
-import { map } from 'rxjs/operators';
-import { forkJoin } from 'rxjs';
-import { Host } from '~/app/shared/models/host.interface';
+import { ActivatedRoute, Router } from '@angular/router';
 
 @Component({
   selector: 'cd-nvmeof-listeners-form',
@@ -25,129 +13,61 @@ import { Host } from '~/app/shared/models/host.interface';
   standalone: false
 })
 export class NvmeofListenersFormComponent implements OnInit {
-  action: string;
-  permission: Permission;
-  hostPermission: Permission;
-  resource: string;
-  pageURL: string;
-  listenerForm: CdFormGroup;
-  subsystemNQN: string;
-  hosts: Array<object> = null;
-  group: string;
+  group!: string;
+  subsystemNQN!: string;
+  isSubmitLoading = false;
+
+  steps: Step[] = [
+    {
+      label: $localize`Listeners`,
+      invalid: false
+    }
+  ];
+
+  title = $localize`Add Listener`;
+  description = $localize`Listeners determine where and how hosts can connect to the subsystem over the network.`;
 
   constructor(
-    public actionLabels: ActionLabelsI18n,
-    private authStorageService: AuthStorageService,
-    private taskWrapperService: TaskWrapperService,
     private nvmeofService: NvmeofService,
-    private hostService: HostService,
+    private taskWrapperService: TaskWrapperService,
     private router: Router,
-    private route: ActivatedRoute,
-    public activeModal: NgbActiveModal,
-    public formatterService: FormatterService,
-    public dimlessBinaryPipe: DimlessBinaryPipe
-  ) {
-    this.permission = this.authStorageService.getPermissions().nvmeof;
-    this.hostPermission = this.authStorageService.getPermissions().hosts;
-    this.resource = $localize`Listener`;
-    this.pageURL = 'block/nvmeof/subsystems';
-  }
-
-  filterHostsByLabel(allHosts: Host[], gwNodesLabel: string | string[]) {
-    return allHosts.filter((host: Host) => {
-      const hostLabels: string[] = host?.labels;
-      if (typeof gwNodesLabel === 'string') {
-        return hostLabels.includes(gwNodesLabel);
-      }
-      return hostLabels?.length === gwNodesLabel?.length && _.isEqual(hostLabels, gwNodesLabel);
-    });
-  }
-
-  filterHostsByHostname(allHosts: Host[], gwNodes: string[]) {
-    return allHosts.filter((host: Host) => gwNodes.includes(host.hostname));
-  }
-
-  getGwGroupPlacement(gwGroups: GatewayGroup[][]) {
-    return (
-      gwGroups?.[0]?.find((gwGroup: GatewayGroup) => gwGroup?.spec?.group === this.group)
-        ?.placement || { hosts: [], label: [] }
-    );
-  }
-
-  setHosts() {
-    forkJoin({
-      gwGroups: this.nvmeofService.listGatewayGroups(),
-      allHosts: this.hostService.getAllHosts()
-    })
-      .pipe(
-        map(({ gwGroups, allHosts }) => {
-          const { hosts, label } = this.getGwGroupPlacement(gwGroups);
-          if (hosts?.length) return this.filterHostsByHostname(allHosts, hosts);
-          else if (label?.length) return this.filterHostsByLabel(allHosts, label);
-          return [];
-        })
-      )
-      .subscribe((nvmeofGwNodes: Host[]) => {
-        this.hosts = nvmeofGwNodes.map((h) => ({ hostname: h.hostname, addr: h.addr }));
-      });
-  }
+    private route: ActivatedRoute
+  ) {}
 
   ngOnInit() {
-    this.createForm();
-    this.action = this.actionLabels.CREATE;
-    this.route.params.subscribe((params: { subsystem_nqn: string }) => {
-      this.subsystemNQN = params?.subsystem_nqn;
-    });
     this.route.queryParams.subscribe((params) => {
       this.group = params?.['group'];
     });
-    this.setHosts();
+    // subsystem_nqn can be in route.params (create/:subsystem_nqn/listener)
+    // or route.parent.params (subsystems/:subsystem_nqn > add/listener)
+    const params = this.route.snapshot.params;
+    const parentParams = this.route.parent?.snapshot.params;
+    this.subsystemNQN = params?.['subsystem_nqn'] || parentParams?.['subsystem_nqn'];
   }
 
-  createForm() {
-    this.listenerForm = new CdFormGroup({
-      host: new UntypedFormControl(null, {
-        validators: [Validators.required]
-      }),
-      trsvcid: new UntypedFormControl(4420, [
-        Validators.required,
-        CdValidators.number(false),
-        Validators.max(65535)
-      ])
-    });
-  }
-
-  buildRequest(): ListenerRequest {
-    const host = this.listenerForm.getValue('host');
-    let trsvcid = Number(this.listenerForm.getValue('trsvcid'));
-    if (!trsvcid) trsvcid = 4420;
-    const request: ListenerRequest = {
-      gw_group: this.group,
-      host_name: host.hostname,
-      traddr: host.addr,
-      trsvcid
-    };
-    return request;
-  }
+  onSubmit(payload: { listeners: ListenerItem[] }) {
+    if (!payload.listeners || payload.listeners.length === 0) {
+      return;
+    }
+    this.isSubmitLoading = true;
+    const taskUrl = `nvmeof/listener/add`;
 
-  onSubmit() {
-    const component = this;
-    const taskUrl: string = `nvmeof/listener/${URLVerbs.CREATE}`;
-    const request = this.buildRequest();
     this.taskWrapperService
       .wrapTaskAroundCall({
         task: new FinishedTask(taskUrl, {
           nqn: this.subsystemNQN,
-          host_name: request.host_name
+          count: payload.listeners.length
         }),
-        call: this.nvmeofService.createListener(this.subsystemNQN, request)
+        call: this.nvmeofService.createListeners(this.subsystemNQN, this.group, payload.listeners)
       })
       .subscribe({
-        error() {
-          component.listenerForm.setErrors({ cdSubmitButton: true });
+        error: () => {
+          this.isSubmitLoading = false;
         },
         complete: () => {
-          this.router.navigate([this.pageURL, { outlets: { modal: null } }], {
+          this.isSubmitLoading = false;
+          this.router.navigate([{ outlets: { modal: null } }], {
+            relativeTo: this.route.parent,
             queryParamsHandling: 'preserve'
           });
         }
index d806ec5402535c2ea010b959a27f3caf8d4ed4ae..368d658506554218a2cbddaef5d9b48cc48d3893 100644 (file)
           [autoReload]="true"
           forceIdentifier="true"
           selectionType="single"
+          emptyStateTitle="No listener found."
+          i18n-emptyStateTitle
+          emptyStateMessage="No listeners found. Add listeners to define network endpoints for hosts"
+          i18n-emptyStateMessage
+          [emptyStateIcon]="iconType.emptySearch"
           (updateSelection)="updateSelection($event)">
   <div class="table-actions">
     <cd-table-actions [permission]="permission"
index 7276afb5521d43d9b691b4cfdff57fd308699b8b..c9b37c714b63ad2b0445ad9cd696851bab4e9c58 100644 (file)
@@ -4,18 +4,18 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CellTemplate } from '~/app/shared/enum/cell-template.enum';
-import { Icons } from '~/app/shared/enum/icons.enum';
+import { Icons, ICON_TYPE } 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 { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { NvmeofListener } from '~/app/shared/models/nvmeof';
+import { Host } from '~/app/shared/models/host.interface';
 import { Permission } from '~/app/shared/models/permissions';
 import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 
-const BASE_URL = 'block/nvmeof/subsystems';
-
 @Component({
   selector: 'cd-nvmeof-listeners-list',
   templateUrl: './nvmeof-listeners-list.component.html',
@@ -28,11 +28,13 @@ export class NvmeofListenersListComponent implements OnInit {
   @Input()
   group: string;
 
-  listenerColumns: any;
+  listenerColumns: CdTableColumn[];
   tableActions: CdTableAction[];
   selection = new CdTableSelection();
   permission: Permission;
   listeners: NvmeofListener[];
+  hasAvailableNodes = true;
+  iconType = ICON_TYPE;
 
   constructor(
     public actionLabels: ActionLabelsI18n,
@@ -84,15 +86,17 @@ export class NvmeofListenersListComponent implements OnInit {
     ];
     this.tableActions = [
       {
-        name: this.actionLabels.CREATE,
+        name: this.actionLabels.ADD,
         permission: 'create',
         icon: Icons.add,
         click: () =>
-          this.router.navigate(
-            [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'listener'] } }],
-            { queryParams: { group: this.group } }
-          ),
-        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+          this.router.navigate([{ outlets: { modal: [URLVerbs.ADD, 'listener'] } }], {
+            queryParams: { group: this.group },
+            relativeTo: this.route.parent
+          }),
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+        disable: () =>
+          !this.hasAvailableNodes ? $localize`All available nodes already have listeners` : false
       },
       {
         name: this.actionLabels.DELETE,
@@ -116,9 +120,23 @@ export class NvmeofListenersListComponent implements OnInit {
           listener['full_addr'] = `${listener.traddr}:${listener.trsvcid}`;
           return listener;
         });
+        this.checkAvailableNodes();
       });
   }
 
+  checkAvailableNodes() {
+    if (!this.group) return;
+    this.nvmeofService.getHostsForGroup(this.group).subscribe({
+      next: (allHosts: Host[]) => {
+        const listenerHostNames = new Set((this.listeners || []).map((l) => l.host_name));
+        this.hasAvailableNodes = allHosts.some((h) => !listenerHostNames.has(h.hostname));
+      },
+      error: () => {
+        this.hasAvailableNodes = true;
+      }
+    });
+  }
+
   deleteListenerModal() {
     const listener = this.selection.first();
     this.modalService.show(DeleteConfirmationModalComponent, {
index 2a185720eebc717ae1c633e9f5deec18a51c781a..d71e41046b8520a72ddd6238ae36fe162dc047a0 100644 (file)
@@ -207,8 +207,7 @@ export class NvmeofNamespacesFormComponent implements OnInit {
         validators: [Validators.required]
       }),
       image_size: new UntypedFormControl(null, {
-        validators: [Validators.required],
-        updateOn: 'blur'
+        validators: [Validators.required]
       }),
       nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [
         Validators.required,
@@ -282,6 +281,15 @@ export class NvmeofNamespacesFormComponent implements OnInit {
     return Math.random().toString(36).substring(2);
   }
 
+  private normalizeImageSizeInput(value: string): string {
+    const input = (value || '').trim();
+    if (!input) {
+      return input;
+    }
+    // Accept plain numeric values as GiB (e.g. "45" => "45GiB").
+    return /^\d+(\.\d+)?$/.test(input) ? `${input}GiB` : input;
+  }
+
   buildCreateRequest(
     rbdImageSize: number,
     nsCount: number,
@@ -344,7 +352,13 @@ export class NvmeofNamespacesFormComponent implements OnInit {
     let rbdImageSize: number = null;
 
     if (image_size) {
-      rbdImageSize = this.formatterService.toBytes(image_size);
+      const normalizedSize = this.normalizeImageSizeInput(image_size);
+      rbdImageSize = this.formatterService.toBytes(normalizedSize);
+      if (rbdImageSize === null) {
+        this.nsForm.get('image_size').setErrors({ invalid: true });
+        this.nsForm.setErrors({ cdSubmitButton: true });
+        return;
+      }
     }
 
     const subsystemNQN = this.nsForm.getValue('subsystem');
index a21879a5d87a9fb9a4d4cf00593bf53e5785dde1..68927708547a05ec08b690664e4dabf9e5eb0d2f 100644 (file)
@@ -88,4 +88,9 @@ describe('NvmeofSubsystemNamespacesListComponent', () => {
     expect(component.namespaces.length).toEqual(2);
     expect(component.namespaces[0].nsid).toEqual(1);
   }));
+  it('should have table actions defined', () => {
+    component.ngOnInit();
+    expect(component.tableActions).toBeDefined();
+    expect(component.tableActions.length).toBeGreaterThan(0);
+  });
 });
index a8c179ef9492e26ec47c23f4845b73382ffdbbda..a811c59308879855577f97ac56d98597cb1035ab 100644 (file)
@@ -7,14 +7,16 @@
        [fullWidth]="true">
     <div cdsCol
          [columnNumbers]="{sm: 4, md: 8}">
+      @if (!listenersOnly) {
       <div cdsRow
            class="form-heading">
         <h3 class="cds--type-heading-03"
             i18n>Subsystem details</h3>
-        <p
-          class="cds--type-label-02"
-          i18n>Enter identifying and network details for this subsystem.</p>
+        <p class="cds--type-label-02"
+           i18n>Enter identifying and network details for this subsystem.</p>
       </div>
+      }
+      @if (!listenersOnly) {
       <div cdsRow
            class="form-item">
         <cds-text-label
@@ -28,6 +30,9 @@
                  cdValidate
                  #nqnRef="cdValidate"
                  type="text"
+                 placeholder="nqn.2001-07.com.ceph:1722347201377"
+                 id="nqn"
+                 name="nqn"
                  formControlName="nqn"
                  [invalid]="nqnRef.isInvalid">
         </cds-text-label>
                  [skeleton]="!group">
         </cds-text-label>
       </div>
+      }
+      <div cdsRow
+           class="form-item">
+        <cds-combo-box i18n
+                       [invalid]="formGroup.get('listeners').invalid && (formGroup.get('listeners').dirty || formGroup.get('listeners').touched)"
+                       [invalidText]="listenersInvalidText"
+                       [label]="listenersLabel"
+                       [helperText]="listenersHelperText"
+                       [title]="listenersLabel"
+                       [items]="hosts"
+                       [type]="'multi'"
+                       formControlName="listeners"
+                       name="listeners">
+          <cds-dropdown-list></cds-dropdown-list>
+        </cds-combo-box>
+        @if (formGroup.get('listeners').value?.length) {
+          <div>
+          @for (listener of formGroup.get('listeners').value; track listener.content; let i = $index) {
+            <cds-tag-filter type="blue"
+                            [title]="listener.content"
+                            (close)="removeListener(i)">
+              {{ listener.content }}
+            </cds-tag-filter>
+          }
+          </div>
+        }
+        <ng-template #listenersLabel>
+          <span i18n>Listeners</span>
+        </ng-template>
+        <ng-template #listenersHelperText>
+          <span i18n>Select listeners for this subsystem.</span>
+        </ng-template>
+        <ng-template #listenersInvalidText>
+          <span i18n>This field is required.</span>
+        </ng-template>
+      </div>
     </div>
   </div>
 </form>
 
+@if (!listenersOnly) {
 <ng-template #nqnInvalidTemplate>
 @for (err of formGroup.get('nqn').errors | keyvalue; track err.key) {
 <span class="invalid-feedback">{{ INVALID_TEXTS[err.key] }}</span>
 }
 </ng-template>
+}
index 5e4c78405de0a28c445b7690eda8cd27baad43ba..b1ab55127f154efe7390b7d1cd532885887875c7 100644 (file)
@@ -12,12 +12,16 @@ import { SharedModule } from '~/app/shared/shared.module';
 import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1.component';
 import { FormHelper } from '~/testing/unit-test-helper';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { GridModule, InputModule } from 'carbon-components-angular';
+import { ComboBoxModule, GridModule, InputModule } from 'carbon-components-angular';
+
+import { of } from 'rxjs';
 
 describe('NvmeofSubsystemsStepOneComponent', () => {
   let component: NvmeofSubsystemsStepOneComponent;
   let fixture: ComponentFixture<NvmeofSubsystemsStepOneComponent>;
+
   let nvmeofService: NvmeofService;
+
   let form: CdFormGroup;
   let formHelper: FormHelper;
   const mockGroupName = 'default';
@@ -25,21 +29,27 @@ describe('NvmeofSubsystemsStepOneComponent', () => {
   beforeEach(async () => {
     await TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemsStepOneComponent],
-      providers: [NgbActiveModal],
       imports: [
         HttpClientTestingModule,
-        NgbTypeaheadModule,
+        SharedModule,
         ReactiveFormsModule,
         RouterTestingModule,
-        SharedModule,
+        NgbTypeaheadModule,
         InputModule,
         GridModule,
+        ComboBoxModule,
         ToastrModule.forRoot()
-      ]
+      ],
+      providers: [NgbActiveModal]
     }).compileComponents();
+  });
 
+  beforeEach(() => {
     fixture = TestBed.createComponent(NvmeofSubsystemsStepOneComponent);
     component = fixture.componentInstance;
+
+    nvmeofService = TestBed.inject(NvmeofService);
+    spyOn(nvmeofService, 'getHostsForGroup').and.returnValue(of([]));
     component.ngOnInit();
     form = component.formGroup;
     formHelper = new FormHelper(form);
index 94cd4442ec1dc15ef578aa44434b54ecdf0c1fa9..866717ef4a56f94f59101abcf321b196b60be824 100644 (file)
@@ -1,4 +1,5 @@
 import { Component, Input, OnInit } from '@angular/core';
+import { forkJoin, of } from 'rxjs';
 import { UntypedFormControl, Validators } from '@angular/forms';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
@@ -8,6 +9,8 @@ import { CdValidators } from '~/app/shared/forms/cd-validators';
 
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+import { Host } from '~/app/shared/models/host.interface';
+import { ListenerItem } from '~/app/shared/models/nvmeof';
 
 @Component({
   selector: 'cd-nvmeof-subsystem-step-one',
@@ -17,6 +20,8 @@ import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
 })
 export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
   @Input() group!: string;
+  @Input() subsystemNQN: string;
+  @Input() listenersOnly = false;
   formGroup: CdFormGroup;
   action: string;
   pageURL: string;
@@ -27,6 +32,8 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
     maxLength: $localize`An NQN may not be more than 223 bytes in length.`
   };
 
+  hosts: ListenerItem[] = [];
+
   constructor(
     public actionLabels: ActionLabelsI18n,
     public activeModal: NgbActiveModal,
@@ -45,29 +52,63 @@ export class NvmeofSubsystemsStepOneComponent implements OnInit, TearsheetStep {
 
   ngOnInit() {
     this.createForm();
+    this.setHosts();
+  }
+
+  setHosts() {
+    const hosts$ = this.nvmeofService.getHostsForGroup(this.group);
+    const listeners$ =
+      this.listenersOnly && this.subsystemNQN
+        ? this.nvmeofService.listListeners(this.subsystemNQN, this.group)
+        : of(null);
+
+    forkJoin([hosts$, listeners$]).subscribe(
+      ([nvmeofGwNodes, existingListeners]: [Host[], any]) => {
+        const listeners = Array.isArray(existingListeners)
+          ? existingListeners
+          : existingListeners?.listeners || [];
+        const consumedHosts = new Set(listeners.map((l: any) => l.host_name));
+        this.hosts = nvmeofGwNodes
+          .map((h) => ({ content: h.hostname, addr: h.addr }))
+          .filter((h) => !consumedHosts.has(h.content));
+      }
+    );
   }
 
   createForm() {
-    this.formGroup = new CdFormGroup({
-      nqn: new UntypedFormControl(this.DEFAULT_NQN, {
-        validators: [
-          this.customNQNValidator,
-          Validators.required,
-          CdValidators.custom(
-            'maxLength',
-            (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
-          )
-        ],
-        asyncValidators: [
-          CdValidators.unique(
-            this.nvmeofService.isSubsystemPresent,
-            this.nvmeofService,
-            null,
-            null,
-            this.group
-          )
-        ]
-      })
-    });
+    if (this.listenersOnly) {
+      this.formGroup = new CdFormGroup({
+        listeners: new UntypedFormControl([])
+      });
+    } else {
+      this.formGroup = new CdFormGroup({
+        nqn: new UntypedFormControl(this.DEFAULT_NQN, {
+          validators: [
+            this.customNQNValidator,
+            Validators.required,
+            CdValidators.custom(
+              'maxLength',
+              (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
+            )
+          ],
+          asyncValidators: [
+            CdValidators.unique(
+              this.nvmeofService.isSubsystemPresent,
+              this.nvmeofService,
+              null,
+              null,
+              this.group
+            )
+          ]
+        }),
+        listeners: new UntypedFormControl([])
+      });
+    }
+  }
+
+  removeListener(index: number) {
+    const listeners = this.formGroup.get('listeners').value;
+    listeners.splice(index, 1);
+    this.formGroup.get('listeners').setValue([...listeners]);
   }
 }
index 4c7e3d6c3f0f91bbaa80e772b0eec839826389fe..cc7fd4a969e1623a146a376e99c1595d46b30512 100644 (file)
@@ -1,5 +1,6 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ReactiveFormsModule } from '@angular/forms';
+import { ActivatedRoute } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 
@@ -14,7 +15,13 @@ import {
 } from './nvmeof-subsystems-form.component';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
-import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+import {
+  ComboBoxModule,
+  GridModule,
+  InputModule,
+  RadioModule,
+  TagModule
+} from 'carbon-components-angular';
 import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component';
 import { HOST_TYPE } from '~/app/shared/models/nvmeof';
 import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component';
@@ -31,7 +38,8 @@ describe('NvmeofSubsystemsFormComponent', () => {
     gw_group: mockGroupName,
     subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
     addedHosts: [],
-    hostType: HOST_TYPE.ALL
+    hostType: HOST_TYPE.ALL,
+    listeners: []
   };
 
   beforeEach(async () => {
@@ -43,7 +51,15 @@ describe('NvmeofSubsystemsFormComponent', () => {
         NvmeofSubsystemsStepThreeComponent,
         NvmeofSubsystemsStepTwoComponent
       ],
-      providers: [NgbActiveModal],
+      providers: [
+        NgbActiveModal,
+        {
+          provide: ActivatedRoute,
+          useValue: {
+            queryParams: of({ group: mockGroupName })
+          }
+        }
+      ],
       imports: [
         HttpClientTestingModule,
         NgbTypeaheadModule,
@@ -54,7 +70,8 @@ describe('NvmeofSubsystemsFormComponent', () => {
         GridModule,
         RadioModule,
         TagModule,
-        ToastrModule.forRoot()
+        ToastrModule.forRoot(),
+        ComboBoxModule
       ]
     }).compileComponents();
 
@@ -62,7 +79,6 @@ describe('NvmeofSubsystemsFormComponent', () => {
     component = fixture.componentInstance;
     component.ngOnInit();
     fixture.detectChanges();
-    component.group = mockGroupName;
   });
 
   it('should create', () => {
@@ -94,7 +110,8 @@ describe('NvmeofSubsystemsFormComponent', () => {
         gw_group: mockGroupName,
         addedHosts: [],
         hostType: HOST_TYPE.ALL,
-        subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY='
+        subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=',
+        listeners: []
       };
 
       component.group = mockGroupName;
index 4012f8ec4a7b359c66533f7f26d31e1f2525a7cc..0f253f997273d2356ff323132d97ae0d5e70a2cf 100644 (file)
@@ -3,12 +3,11 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
-import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { ActivatedRoute, Router } from '@angular/router';
 import { Step } from 'carbon-components-angular';
 import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component';
-import { HOST_TYPE } from '~/app/shared/models/nvmeof';
+import { HOST_TYPE, ListenerItem } from '~/app/shared/models/nvmeof';
 import { from, Observable, of } from 'rxjs';
 import { NotificationService } from '~/app/shared/services/notification.service';
 import { NotificationType } from '~/app/shared/enum/notification-type.enum';
@@ -21,6 +20,7 @@ export type SubsystemPayload = {
   subsystemDchapKey: string;
   addedHosts: string[];
   hostType: string;
+  listeners: ListenerItem[];
 };
 
 type StepResult = { step: string; success: boolean; error?: string };
@@ -32,7 +32,6 @@ type StepResult = { step: string; success: boolean; error?: string };
   standalone: false
 })
 export class NvmeofSubsystemsFormComponent implements OnInit {
-  subsystemForm: CdFormGroup;
   action: string;
   group: string;
   steps: Step[] = [
@@ -91,16 +90,27 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
       .subscribe({
         next: () => {
           stepResults.push({ step: this.steps[0].label, success: true });
-          this.runSequentialSteps(
-            [
-              {
-                step: this.steps[1].label,
-                call: () =>
-                  this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest)
-              }
-            ],
-            stepResults
-          ).subscribe({
+          const sequentialSteps: { step: string; call: () => Observable<any> }[] = [];
+
+          if (payload.listeners && payload.listeners.length > 0) {
+            sequentialSteps.push({
+              step: $localize`Listeners`,
+              call: () =>
+                this.nvmeofService.createListeners(
+                  `${payload.nqn}.${this.group}`,
+                  this.group,
+                  payload.listeners
+                )
+            });
+          }
+
+          sequentialSteps.push({
+            step: this.steps[1].label,
+            call: () =>
+              this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest)
+          });
+
+          this.runSequentialSteps(sequentialSteps, stepResults).subscribe({
             complete: () => this.showFinalNotification(stepResults)
           });
         },
index 91bdf4e9bf7125623b0e6f40c3570788e43a3cb5..d3efcb3facaaefb96376505e070cd2bb85693512 100644 (file)
@@ -1,11 +1,4 @@
-import {
-  Component,
-  OnDestroy,
-  OnInit,
-  TemplateRef,
-  ViewChild,
-  ChangeDetectorRef
-} from '@angular/core';
+import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
 import { ActivatedRoute, Router } from '@angular/router';
 import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
@@ -82,8 +75,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     private router: Router,
     private route: ActivatedRoute,
     private modalService: ModalCdsService,
-    private taskWrapper: TaskWrapperService,
-    private cdRef: ChangeDetectorRef
+    private taskWrapper: TaskWrapperService
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
@@ -161,7 +153,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
       }),
       tap((subs) => {
         this.subsystems = subs;
-        this.expandPendingSubsystem();
       }),
       takeUntil(this.destroy$)
     );
@@ -260,19 +251,6 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     this.context?.error?.(error);
   }
 
-  private expandPendingSubsystem() {
-    if (!this.pendingNqn) return;
-    const match = this.subsystems.find((s) => s.nqn === this.pendingNqn);
-    if (match && this.table) {
-      setTimeout(() => {
-        this.table.expanded = match;
-        this.table.toggleExpandRow();
-        this.cdRef.detectChanges();
-      });
-    }
-    this.pendingNqn = null;
-  }
-
   private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) {
     return this.nvmeofService.getInitiators(sub.nqn, this.group).pipe(
       catchError(() => of([])),
index ede904c2f646c99d4a0f6b91c8c53643809844b6..37e8932c365e67bc4fad443a19ded2b6beaa4650 100755 (executable)
@@ -167,7 +167,7 @@ describe('NvmeofService', () => {
         enable_ha: true,
         initiators: '*',
         gw_group: mockGroupName,
-        dhchap_key: ''
+        dhchap_key: null
       };
       service.createSubsystem(request).subscribe();
       const req = httpTesting.expectOne(`${API_PATH}/subsystem`);
@@ -247,9 +247,7 @@ describe('NvmeofService', () => {
     const mockNsid = '1';
     it('should call listNamespaces', () => {
       service.listNamespaces(mockGroupName).subscribe();
-      const req = httpTesting.expectOne(
-        `${API_PATH}/subsystem/*/namespace?gw_group=${mockGroupName}`
-      );
+      const req = httpTesting.expectOne(`${API_PATH}/subsystem/*/namespace?gw_group=${mockGroupName}`);
       expect(req.request.method).toBe('GET');
     });
     it('should call getNamespace', () => {
@@ -285,4 +283,134 @@ describe('NvmeofService', () => {
       expect(req.request.method).toBe('DELETE');
     });
   });
+
+  describe('getHostsForGroup', () => {
+    const allHosts = [
+      { hostname: 'host1', labels: ['nvmeof'], status: '' },
+      { hostname: 'host2', labels: ['storage'], status: '' },
+      { hostname: 'host3', labels: ['nvmeof', 'storage'], status: '' }
+    ];
+
+    it('should filter hosts by direct host placement', (done) => {
+      const mockGroups = [
+        [{ spec: { group: 'default' }, placement: { hosts: ['host1', 'host3'], label: [] } }]
+      ];
+      mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+      service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+        expect(hosts.length).toBe(2);
+        expect(hosts.map((h: any) => h.hostname)).toEqual(['host1', 'host3']);
+        done();
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      req.flush(mockGroups);
+    });
+
+    it('should filter hosts by string label placement', (done) => {
+      const mockGroups = [
+        [{ spec: { group: 'default' }, placement: { hosts: [], label: 'nvmeof' } }]
+      ];
+      mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+      service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+        expect(hosts.length).toBe(2);
+        expect(hosts.map((h: any) => h.hostname)).toEqual(['host1', 'host3']);
+        done();
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      req.flush(mockGroups);
+    });
+
+    it('should filter hosts by array label placement', (done) => {
+      const mockGroups = [
+        [{ spec: { group: 'default' }, placement: { hosts: [], label: ['nvmeof', 'storage'] } }]
+      ];
+      mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+      service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+        expect(hosts.length).toBe(1);
+        expect(hosts[0].hostname).toBe('host3');
+        done();
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      req.flush(mockGroups);
+    });
+
+    it('should return empty array when group not found', (done) => {
+      const mockGroups = [
+        [{ spec: { group: 'other' }, placement: { hosts: ['host1'], label: [] } }]
+      ];
+      mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+      service.getHostsForGroup('non-existent').subscribe((hosts: any[]) => {
+        expect(hosts.length).toBe(0);
+        done();
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      req.flush(mockGroups);
+    });
+
+    it('should return empty array when placement has no hosts or labels', (done) => {
+      const mockGroups = [[{ spec: { group: 'default' }, placement: { hosts: [], label: [] } }]];
+      mockHostService.getAllHosts.mockReturnValue(of(allHosts));
+
+      service.getHostsForGroup('default').subscribe((hosts: any[]) => {
+        expect(hosts.length).toBe(0);
+        done();
+      });
+
+      const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+      req.flush(mockGroups);
+    });
+  });
+
+  describe('createListeners', () => {
+    it('should call createListener for each listener in the array', () => {
+      const listeners = [
+        { content: 'ceph-node-01', addr: '192.168.1.1' },
+        { content: 'ceph-node-02', addr: '192.168.1.2' }
+      ];
+
+      service.createListeners('nqn.test', 'default', listeners).subscribe();
+
+      const reqs = httpTesting.match(`${API_PATH}/subsystem/nqn.test/listener`);
+      expect(reqs.length).toBe(2);
+      expect(reqs[0].request.method).toBe('POST');
+      expect(reqs[0].request.body).toEqual({
+        gw_group: 'default',
+        host_name: 'ceph-node-01',
+        traddr: '192.168.1.1',
+        trsvcid: 4420
+      });
+      expect(reqs[1].request.body).toEqual({
+        gw_group: 'default',
+        host_name: 'ceph-node-02',
+        traddr: '192.168.1.2',
+        trsvcid: 4420
+      });
+
+      reqs.forEach((req) => req.flush({}, { status: 200, statusText: 'OK' }));
+    });
+
+    it('should call createListener for a single listener', () => {
+      const listeners = [{ content: 'ceph-node-01', addr: '192.168.1.1' }];
+
+      service.createListeners('nqn.test', 'group1', listeners).subscribe();
+
+      const reqs = httpTesting.match(`${API_PATH}/subsystem/nqn.test/listener`);
+      expect(reqs.length).toBe(1);
+      expect(reqs[0].request.body).toEqual({
+        gw_group: 'group1',
+        host_name: 'ceph-node-01',
+        traddr: '192.168.1.1',
+        trsvcid: 4420
+      });
+
+      reqs[0].flush({}, { status: 200, statusText: 'OK' });
+    });
+  });
 });
index 12641a0f2784d8a784bbe0be91d6381810911a72..f5833f3ca3ebfffa1797348d16a12454123dcc1c 100644 (file)
@@ -4,8 +4,8 @@ import { HttpClient } from '@angular/common/http';
 import _ from 'lodash';
 import { Observable, forkJoin, of as observableOf } from 'rxjs';
 import { catchError, map, mapTo, mergeMap } from 'rxjs/operators';
-import { NvmeofSubsystemNamespace } from '../models/nvmeof';
 import { CephServiceSpec } from '../models/service.interface';
+import { ListenerItem } from '../models/nvmeof';
 import { HostService } from './host.service';
 import { OrchestratorService } from './orchestrator.service';
 import { HostStatus } from '../enum/host-status.enum';
@@ -37,8 +37,8 @@ export type NamespaceCreateRequest = NvmeofRequest & {
   rbd_pool: string;
   rbd_image_size?: number;
   no_auto_visible?: boolean;
-  create_image: boolean;
   block_size?: number;
+  create_image: boolean;
 };
 
 export type NamespaceUpdateRequest = NvmeofRequest & {
@@ -111,6 +111,35 @@ export class NvmeofService {
     });
   }
 
+  getHostsForGroup(groupName: string): Observable<Host[]> {
+    return forkJoin({
+      gwGroups: this.listGatewayGroups(),
+      allHosts: this.hostService.getAllHosts()
+    }).pipe(
+      map(({ gwGroups, allHosts }) => {
+        const group = gwGroups?.[0]?.find(
+          (gwGroup: CephServiceSpec) => gwGroup?.spec?.group === groupName
+        );
+        const placement = group?.placement || { hosts: [], label: [] };
+        const { hosts, label } = placement;
+
+        if (hosts?.length) {
+          return allHosts.filter((host: Host) => hosts.includes(host.hostname));
+        } else if (label?.length) {
+          if (typeof label === 'string') {
+            return allHosts.filter((host: Host) => host?.labels?.includes(label));
+          }
+          return allHosts.filter(
+            (host: Host) =>
+              host?.labels?.length === label?.length &&
+              _.isEqual([...host.labels].sort(), [...label].sort())
+          );
+        }
+        return [];
+      })
+    );
+  }
+
   // formats the gateway groups to be consumed for combobox item
   formatGwGroupsList(
     data: CephServiceSpec[][],
@@ -188,6 +217,16 @@ export class NvmeofService {
     });
   }
 
+  addNamespaceInitiators(nsid: number | string, request: NamespaceInitiatorRequest) {
+    return this.http.post(
+      `${UI_API_PATH}/subsystem/${request.subsystem_nqn}/namespace/${nsid}/host`,
+      request,
+      {
+        observe: 'response'
+      }
+    );
+  }
+
   updateHostKey(subsystemNQN: string, request: InitiatorRequest) {
     return this.http.put(
       `${API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/change_key`,
@@ -198,12 +237,6 @@ export class NvmeofService {
     );
   }
 
-  addNamespaceInitiators(nsid: string, request: NamespaceInitiatorRequest) {
-    return this.http.post(`${UI_API_PATH}/namespace/${nsid}/host`, request, {
-      observe: 'response'
-    });
-  }
-
   removeInitiators(subsystemNQN: string, request: InitiatorRequest) {
     return this.http.delete(
       `${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}/${request.gw_group}`,
@@ -224,6 +257,18 @@ export class NvmeofService {
     });
   }
 
+  createListeners(subsystemNQN: string, gwGroup: string, listeners: ListenerItem[]) {
+    const listenerCalls = listeners.map((listener: ListenerItem) =>
+      this.createListener(subsystemNQN, {
+        gw_group: gwGroup,
+        host_name: listener.content,
+        traddr: listener.addr,
+        trsvcid: 4420
+      })
+    );
+    return forkJoin(listenerCalls);
+  }
+
   deleteListener(
     subsystemNQN: string,
     group: string,
@@ -243,11 +288,6 @@ export class NvmeofService {
       }
     );
   }
-  listSubsystemNamespaces(subsystemNQN: string) {
-    return this.http.get<NvmeofSubsystemNamespace[]>(
-      `${API_PATH}/subsystem/${subsystemNQN}/namespace`
-    );
-  }
 
   // Namespaces
   listNamespaces(group: string, subsystemNQN: string = '*') {
index 1c4f562d14730fd1fa6452e254269be162066617..76dcec8cb1eb68ce5f5a265a0f89229fe03a2dda 100644 (file)
@@ -78,7 +78,7 @@
 @else {
 <!-- Wide Tearsheet -->
 <cds-modal
-  size="lg"
+  [size]="size"
   [open]="isOpen"
   (overlaySelected)="closeTearsheet()">
   <!-- Tearsheet Header -->
index bbcd84b1701ab60cfea215dac06a35df93005332..b2157fe3b63bb408a4aeb40a9bebb463c8d71e12 100644 (file)
   flex-direction: column;
 }
 
+:host ::ng-deep .cds--modal-container.cds--modal-container--sm {
+  inset-block-start: 88px;
+  block-size: 50vh;
+  max-block-size: 50vh;
+  inset-inline-start: 35%;
+  inset-inline-end: 35%;
+  inline-size: auto;
+  max-inline-size: none;
+}
+
 // FULL TEARSHEET
 .tearsheet--full {
   height: 100%;
index 71419dd266406483555057ad2e3ec6b0fda52d7a..8f416ccb2a5bc1b9ce9994ac0113875c7e4afff7 100644 (file)
@@ -61,6 +61,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy {
   @Input() steps!: Array<Step>;
   @Input() description!: string;
   @Input() type: 'full' | 'wide' = 'wide';
+  @Input() size: 'xs' | 'sm' | 'md' | 'lg' = 'lg';
   @Input() submitButtonLabel: string = $localize`Create`;
   @Input() submitButtonLoadingLabel: string = $localize`Creating`;
   @Input() isSubmitLoading: boolean = true;
index 26d17b81d6b81129b8bbdcd26f6bf58011bf5a26..36061c628f8050d3f3c9b07537d540246e3b0607 100644 (file)
@@ -59,8 +59,7 @@ export interface NvmeofSubsystemNamespace {
   rw_mbytes_per_second: number | string;
   r_mbytes_per_second: number | string;
   w_mbytes_per_second: number | string;
-  ns_subsystem_nqn?: string; // Field from JSON
-  subsystem_nqn?: string; // Keep for compatibility if needed, but JSON has ns_subsystem_nqn
+  subsystem_nqn?: string; // Field from JSON (mapped from ns_subsystem_nqn if needed)
 }
 
 export interface NvmeofGatewayGroup extends CephServiceSpec {
@@ -83,6 +82,11 @@ export const HOST_TYPE = {
   SPECIFIC: 'specific'
 };
 
+export interface ListenerItem {
+  content: string;
+  addr: string;
+}
+
 /**
  * Determines the authentication status of a subsystem based on PSK and initiators.
  * Can be reused across subsystem pages.
index bd4ddc5f85fc203b8c7ffcebfd202cf864e47849..db8228f0f7219db323aacf0d2f84c66b5fb347a4 100644 (file)
@@ -394,6 +394,9 @@ export class TaskMessageService {
     'nvmeof/listener/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.nvmeofListener(metadata)
     ),
+    'nvmeof/listener/add': this.newTaskMessage(this.commonOperations.add, (metadata) =>
+      this.nvmeofListenerPlural(metadata)
+    ),
     'nvmeof/listener/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
       this.nvmeofListener(metadata)
     ),
@@ -608,7 +611,11 @@ export class TaskMessageService {
     return $localize`hosts to gateway group '${metadata.group_name}'`;
   }
   nvmeofListener(metadata: any) {
-    return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
+    return $localize`listener '${metadata.host_name}' for subsystem ${metadata.nqn}`;
+  }
+
+  nvmeofListenerPlural(metadata: { count: number; nqn: string }) {
+    return $localize`${this.pluralize('listener', metadata.count)} to subsystem ${metadata.nqn}`;
   }
 
   nvmeofNamespace(metadata: { nqn: string; nsCount?: number; nsid?: string }) {