]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard : Add gateway filtering support for subsystem and namespace lists 69243/head
authorpujashahu <pujashahu@li-3d22414c-327d-11b2-a85c-a49077722390.ibm.com>
Tue, 2 Jun 2026 13:11:27 +0000 (18:41 +0530)
committerpujashahu <pshahu@redhat.com>
Fri, 3 Jul 2026 07:52:06 +0000 (13:22 +0530)
Fixes: https://tracker.ceph.com/issues/77068
Signed-off-by: pujaoshahu <pshahu@redhat.com>
(cherry picked from commit e838bb19897ed4a8f6a548eca08e4fdd726e62f5)

 Conflicts:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts

23 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/gateway-group-query-handler.service.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.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/nvmeof-subsystems.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
src/pybind/mgr/dashboard/frontend/src/styles.scss

diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/gateway-group-query-handler.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/gateway-group-query-handler.service.ts
new file mode 100644 (file)
index 0000000..51a30d5
--- /dev/null
@@ -0,0 +1,153 @@
+import { Injectable, OnDestroy } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
+
+@Injectable()
+export class GatewayGroupQueryHandlerService implements OnDestroy {
+  group: string = null;
+  gwGroups: GroupsComboboxItem[] = [];
+  groupSelectionCleared = false;
+  gwGroupsEmpty = false;
+  gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER;
+
+  dataRefresh$ = new Subject<void>();
+  private destroy$ = new Subject<void>();
+
+  constructor(
+    private route: ActivatedRoute,
+    private router: Router,
+    private nvmeofService: NvmeofService
+  ) {}
+
+  init() {
+    this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+      const group = params?.['group']?.trim() || null;
+      const hasGroupParam = Object.prototype.hasOwnProperty.call(params ?? {}, 'group');
+      if (group) {
+        if (this.group === group && !this.groupSelectionCleared) {
+          return;
+        }
+        this.groupSelectionCleared = false;
+        this.onGroupSelection({ content: group }, false);
+      } else if (hasGroupParam) {
+        if (!this.group && this.groupSelectionCleared) {
+          return;
+        }
+        this.groupSelectionCleared = true;
+        if (this.group) {
+          this.onGroupClear(false);
+        } else {
+          this.dataRefresh$.next();
+        }
+      } else {
+        if (!this.group && !this.groupSelectionCleared) {
+          return;
+        }
+        this.groupSelectionCleared = false;
+        this.group = null;
+      }
+    });
+    this.fetchGatewayGroups();
+  }
+
+  onGroupChange(group: string | null): void {
+    if (group) {
+      this.onGroupSelection({ content: group });
+    } else {
+      this.onGroupClear();
+    }
+  }
+
+  onGroupSelection(selected: GroupsComboboxItem, syncQueryParam = true) {
+    selected.selected = true;
+    this.group = selected.content;
+    this.groupSelectionCleared = false;
+    if (syncQueryParam) {
+      this.syncGroupQueryParam(this.group);
+    }
+    this.dataRefresh$.next();
+  }
+
+  onGroupClear(syncQueryParam = true) {
+    this.group = null;
+    this.groupSelectionCleared = true;
+    if (syncQueryParam) {
+      this.syncGroupQueryParam(null);
+    }
+    this.dataRefresh$.next();
+  }
+
+  private syncGroupQueryParam(group: string | null): void {
+    const currentGroup = this.route.snapshot.queryParams['group']?.trim() || null;
+    if (currentGroup === group) {
+      return;
+    }
+    this.router.navigate([], {
+      relativeTo: this.route,
+      queryParams: { group: group || null },
+      queryParamsHandling: 'merge',
+      replaceUrl: true
+    });
+  }
+
+  private fetchGatewayGroups() {
+    this.nvmeofService
+      .listGatewayGroups()
+      .pipe(takeUntil(this.destroy$))
+      .subscribe({
+        next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response),
+        error: (error) => this.handleGatewayGroupsError(error)
+      });
+  }
+
+  private handleGatewayGroupsSuccess(response: CephServiceSpec[][]) {
+    if (response?.[0]?.length) {
+      this.gwGroups = this.nvmeofService.formatGwGroupsList(response);
+    } else {
+      this.gwGroups = [];
+    }
+    this.updateGroupSelectionState();
+  }
+
+  private updateGroupSelectionState() {
+    if (this.gwGroups.length) {
+      if (this.group) {
+        this.gwGroups = this.gwGroups.map((g) => ({
+          ...g,
+          selected: g.content === this.group
+        }));
+      } else if (!this.groupSelectionCleared) {
+        this.onGroupSelection(this.gwGroups[0]);
+      } else {
+        this.gwGroups = this.gwGroups.map((g) => ({
+          ...g,
+          selected: false
+        }));
+      }
+      this.gwGroupsEmpty = false;
+      this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER;
+    } else {
+      this.gwGroupsEmpty = true;
+      this.gwGroupPlaceholder = $localize`No groups available`;
+    }
+  }
+
+  private handleGatewayGroupsError(error: any) {
+    this.gwGroups = [];
+    this.gwGroupsEmpty = true;
+    this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`;
+    if (error?.preventDefault) {
+      error?.preventDefault?.();
+    }
+  }
+
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+}
index c04056a900d24b99eb9b925d0a8154210870fadf..1d2b25733f2e6025bcf635aedec95832c2134721 100644 (file)
@@ -1,22 +1,14 @@
-<div cdsGrid
-     [useCssGrid]="true"
-     [narrow]="true"
-     [fullWidth]="true">
-<div cdsCol
-     [columnNumbers]="{sm: 4, md: 8}">
-  <div class="form-item cds-mt-5"
-       cdsRow>
-    <cds-combo-box
-        type="single"
-        label="Selected Gateway Group"
-        i18n-label
-        [placeholder]="placeholder"
-        [items]="items"
-        (selected)="onSelected($event)"
-        (clear)="onClear()"
-        [disabled]="disabled">
-      <cds-dropdown-list></cds-dropdown-list>
-    </cds-combo-box>
-  </div>
-</div>
+<div class="nvmeof-gateway-group-filter cds-mt-3">
+  <span class="nvmeof-gateway-group-filter__label cds--type-body-compact-01"
+        i18n>Gateway group:</span>
+  <cds-combo-box type="single"
+                 size="sm"
+                 class="nvmeof-gateway-group-filter__combo"
+                 [placeholder]="placeholder"
+                 [items]="items"
+                 [disabled]="disabled"
+                 (selected)="onSelected($event)"
+                 (clear)="onClear()">
+    <cds-dropdown-list></cds-dropdown-list>
+  </cds-combo-box>
 </div>
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..efb980064029b9a3703e047099f8f4c60128f83d 100644 (file)
@@ -0,0 +1,43 @@
+.nvmeof-gateway-group-filter {
+  display: flex;
+  align-items: center;
+  gap: var(--cds-spacing-03);
+  margin-left: var(--cds-spacing-05);
+
+  &__label {
+    white-space: nowrap;
+    color: var(--cds-text-secondary);
+    font-weight: 600;
+  }
+
+  &__combo {
+    min-inline-size: 14rem;
+    max-inline-size: 20rem;
+
+    // Remove bottom border from combobox - target all possible elements
+    ::ng-deep {
+      .cds--combo-box,
+      .cds--text-input,
+      .cds--list-box,
+      .cds--text-input__field-wrapper,
+      input,
+      input[role='combobox'] {
+        border-bottom: none !important;
+      }
+
+      // Also remove from parent containers
+      .cds--form-requirement,
+      .cds--text-input__label,
+      .cds--list-box__wrapper {
+        border-bottom: none !important;
+      }
+
+      // Target the input on all states
+      input:focus,
+      input:hover,
+      input:active {
+        border-bottom: none !important;
+      }
+    }
+  }
+}
index 2324b2ce161206ea4d1e803de7f89f0380773c1f..4e16ebd11a9046497a113bada4e3ba0be8664432 100644 (file)
@@ -103,11 +103,13 @@ describe('NvmeofGatewayGroupFilterComponent', () => {
     expect(component.placeholder).toBe('Unable to fetch Gateway groups');
   });
 
-  it('should call preventDefault on the error when the API call fails', () => {
-    const err = { preventDefault: jest.fn() };
-    (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(throwError(err));
+  it('should handle non-Error API failures by disabling the control', () => {
+    const err = { message: 'network error' };
+    (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(throwError(() => err));
     fixture.detectChanges();
-    expect(err.preventDefault).toHaveBeenCalled();
+    expect(component.disabled).toBe(true);
+    expect(component.placeholder).toBe('Unable to fetch Gateway groups');
+    expect(component.items).toEqual([]);
   });
 
   it('should render a cds-combo-box', () => {
index 39dd35faa3a20c83b3730bfd171b0d06a1355e98..376aa4fe9eb8b2a0ac3d28c1a0fbd8d10c64ba03 100644 (file)
-import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core';
-import { ActivatedRoute, Router, RouterModule } from '@angular/router';
+import {
+  Component,
+  EventEmitter,
+  OnDestroy,
+  OnInit,
+  Output,
+  ViewEncapsulation
+} from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
 import { Subject } from 'rxjs';
 import { takeUntil } from 'rxjs/operators';
-import { ComboBoxModule, GridModule, LayoutModule } from 'carbon-components-angular';
+import { ComboBoxModule } from 'carbon-components-angular';
 import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
-import { CephServiceSpec } from '~/app/shared/models/service.interface';
-
-const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
 
 @Component({
   selector: 'cd-nvmeof-gateway-group-filter',
   templateUrl: './nvmeof-gateway-group-filter.component.html',
   styleUrls: ['./nvmeof-gateway-group-filter.component.scss'],
   standalone: true,
-  imports: [ComboBoxModule, GridModule, LayoutModule, RouterModule]
+  imports: [ComboBoxModule],
+  encapsulation: ViewEncapsulation.None
 })
 export class NvmeofGatewayGroupFilterComponent implements OnInit, OnDestroy {
   @Output() groupChange = new EventEmitter<string | null>();
 
   items: GroupsComboboxItem[] = [];
-  disabled = false;
-  placeholder = DEFAULT_PLACEHOLDER;
+  placeholder: string = '';
+  disabled: boolean = false;
 
   private destroy$ = new Subject<void>();
 
   constructor(
     private nvmeofService: NvmeofService,
-    private route: ActivatedRoute,
-    private router: Router
+    private router: Router,
+    private route: ActivatedRoute
   ) {}
 
   ngOnInit(): void {
-    this.loadGatewayGroups();
-  }
-
-  ngOnDestroy(): void {
-    this.destroy$.next();
-    this.destroy$.complete();
-  }
-
-  onSelected(item: GroupsComboboxItem): void {
-    this.syncQueryParam(item.content);
-  }
-
-  onClear(): void {
-    this.syncQueryParam(null);
-  }
-
-  private loadGatewayGroups(): void {
     this.nvmeofService
       .listGatewayGroups()
       .pipe(takeUntil(this.destroy$))
       .subscribe({
-        next: (response: CephServiceSpec[][]) => this.onGroupsLoaded(response),
-        error: (error: any) => this.onGroupsError(error)
+        next: (response: any) => {
+          if (!response?.[0]?.length) {
+            this.disabled = true;
+            this.placeholder = $localize`No groups available`;
+            this.items = [];
+            return;
+          }
+          const formatted: GroupsComboboxItem[] = this.nvmeofService.formatGwGroupsList(response);
+          const currentGroup: string | undefined = this.route.snapshot.queryParams['group'];
+          if (currentGroup) {
+            this.items = formatted.map((item) => ({
+              ...item,
+              selected: item.content === currentGroup
+            }));
+          } else {
+            this.items = formatted;
+            const first = formatted[0];
+            this.router.navigate([], {
+              relativeTo: this.route,
+              queryParams: { group: first.content },
+              queryParamsHandling: 'merge',
+              replaceUrl: true
+            });
+          }
+        },
+        error: () => {
+          this.disabled = true;
+          this.placeholder = $localize`Unable to fetch Gateway groups`;
+          this.items = [];
+        }
       });
   }
 
-  private onGroupsLoaded(response: CephServiceSpec[][]): void {
-    if (response?.[0]?.length) {
-      this.items = this.nvmeofService.formatGwGroupsList(response);
-    } else {
-      this.items = [];
-    }
-    this.syncSelectionState();
+  onSelected(item: GroupsComboboxItem): void {
+    this.groupChange.emit(item.content);
   }
 
-  private onGroupsError(error: any): void {
-    this.items = [];
-    this.disabled = true;
-    this.placeholder = $localize`Unable to fetch Gateway groups`;
-    if (error?.preventDefault) {
-      error.preventDefault();
-    }
+  onClear(): void {
     this.groupChange.emit(null);
   }
 
-  private syncSelectionState(): void {
-    if (this.items.length) {
-      this.disabled = false;
-      this.placeholder = DEFAULT_PLACEHOLDER;
-      const urlGroup = this.route.snapshot.queryParams['group']?.trim() || null;
-      if (!urlGroup) {
-        this.syncQueryParam(this.items[0].content);
-      } else {
-        this.items = this.items.map((g) => ({ ...g, selected: g.content === urlGroup }));
-        this.groupChange.emit(urlGroup);
-      }
-    } else {
-      this.disabled = true;
-      this.placeholder = $localize`No groups available`;
-      this.groupChange.emit(null);
-    }
-  }
-
-  private syncQueryParam(group: string | null): void {
-    const currentGroup = this.route.snapshot.queryParams['group']?.trim() || null;
-    if (currentGroup === group) {
-      // URL already correct — still emit so the parent gets the current value on init
-      this.items = this.items.map((g) => ({ ...g, selected: g.content === group }));
-      this.groupChange.emit(group);
-      return;
-    }
-
-    this.router.navigate([], {
-      relativeTo: this.route,
-      queryParams: { group: group || null },
-      queryParamsHandling: 'merge',
-      replaceUrl: true
-    });
-    this.items = this.items.map((g) => ({ ...g, selected: g.content === group }));
-    this.groupChange.emit(group);
+  ngOnDestroy(): void {
+    this.destroy$.next();
+    this.destroy$.complete();
   }
 }
index 8d4007806bcf981c3ff9dcb8482420e63525f78d..285a5705538478341e74b047daf9b88c962c9648 100644 (file)
@@ -1,27 +1,28 @@
-<ng-container *ngIf="gatewayGroup$ | async as gateways">
-  <cd-table
-    #table
-    [data]="gateways"
-    [columns]="columns"
-    columnMode="flex"
-    selectionType="single"
-    identifier="name"
-    (updateSelection)="updateSelection($event)"
-    (fetchData)="fetchData()"
-    emptyStateTitle="No gateway group created"
-    i18n-emptyStateTitle
-    emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
-    i18n-emptyStateMessage>
-
-    <div class="table-actions">
-      <cd-table-actions [permission]="permission"
+<div class="nvmeof-content-layout">
+  <div class="nvmeof-content-main">
+    <ng-container *ngIf="gatewayGroup$ | async as gateways">
+      <cd-table
+        #table
+        [data]="gateways"
+        [columns]="columns"
+        columnMode="flex"
+        selectionType="single"
+        identifier="name"
+        (updateSelection)="updateSelection($event)"
+        (fetchData)="fetchData()"
+        emptyStateTitle="No gateway group created"
+        i18n-emptyStateTitle
+        emptyStateMessage="Set up your first gateway group to start using NVMe over Fabrics. This will allow you to create high-performance block storage with NVMe/TCP protocol."
+        i18n-emptyStateMessage>
+      <cd-table-actions class="table-actions"
+                        [permission]="permission"
                         [selection]="selection"
-                        class="btn-group"
                         [tableActions]="tableActions">
       </cd-table-actions>
-    </div>
-  </cd-table>
-</ng-container>
+      </cd-table>
+    </ng-container>
+  </div>
+</div>
 
 <ng-template #dateTpl
              let-created="data.value">
index dcaced4aaaa9d6aef753e4099e57a265d5c4f6b0..93055deeddfb5149fdc30f792129bb7fbc5b21d8 100644 (file)
@@ -22,7 +22,6 @@ describe('NvmeofGatewayGroupComponent', () => {
       listGatewayGroups: jest.fn().mockReturnValue(of([])),
       listSubsystems: jest.fn().mockReturnValue(of([]))
     };
-
     const nvmeofStateServiceMock = {
       refresh$: new Subject<void>(),
       requestRefresh: jest.fn()
index b59f235e8affaa80c3d69780f1464c54d33c03f1..b590f6b1048965173b0ddf78330451c501fd0e58 100644 (file)
@@ -77,7 +77,6 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
 
   viewUrl = `/${BASE_URL}/view`;
   icons = Icons;
-
   iconSize = IconSize;
 
   constructor(
@@ -182,7 +181,8 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
           }),
           catchError(() => {
             return of([]);
-          })
+          }),
+          finalize(() => this.setTableLoading(false))
         )
       ),
       shareReplay({ bufferSize: 1, refCount: true }),
@@ -200,10 +200,17 @@ export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy {
       .subscribe(() => this.fetchData());
   }
   fetchData(): void {
+    this.setTableLoading(true);
     this.subject.next([]);
     this.checkNodesAvailability();
   }
 
+  private setTableLoading(loading: boolean): void {
+    if (this.table) {
+      this.table.loadingIndicator = loading;
+    }
+  }
+
   updateSelection(selection: CdTableSelection): void {
     this.selection = selection;
   }
index 38c5c2fc78c3f73421cdd48c70835102adaf0639..c8db104d65af968b8df8c656d5e163e5bfea5bed 100644 (file)
@@ -1,9 +1,8 @@
-<cd-nvmeof-gateway-group-filter
-  (groupChange)="onGroupChange($event)">
-</cd-nvmeof-gateway-group-filter>
-
+<div class="nvmeof-content-layout">
+  <div class="nvmeof-content-main">
 <ng-container *ngIf="namespaces$ | async as namespaces">
   <cd-table [data]="namespaces"
+            [compactSearchField]="true"
             columnMode="flex"
             (fetchData)="fetchData()"
             [columns]="namespacesColumns"
             emptyStateMessage="Namespaces are storage volumes mapped to subsystems for host access. Create a namespace to start provisioning storage within a subsystem."
             i18n-emptyStateMessage>
 
+    <div class="table-filter">
+      <cd-nvmeof-gateway-group-filter
+        (groupChange)="groupHandler.onGroupChange($event)">
+      </cd-nvmeof-gateway-group-filter>
+    </div>
+
   <div class="table-actions">
     <cd-table-actions [permission]="permission"
                       [selection]="selection"
@@ -25,5 +30,7 @@
   </div>
 </cd-table>
 </ng-container>
+  </div>
+</div>
 
 <router-outlet name="modal"></router-outlet>
index 945c99ea0406b7ad68bf653e22877b8c6f3461c0..e49ba58057fefd2eb2866e2807d53963530e8940 100644 (file)
@@ -1,8 +1,9 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { HttpClientModule } from '@angular/common/http';
-import { NO_ERRORS_SCHEMA } from '@angular/core';
-import { of, Subject } from 'rxjs';
-import { take } from 'rxjs/operators';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { BehaviorSubject, Subject, of } from 'rxjs';
+import { skip, take } from 'rxjs/operators';
 import { RouterTestingModule } from '@angular/router/testing';
 import { SharedModule } from '~/app/shared/shared.module';
 
@@ -13,6 +14,7 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
 import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list.component';
+import { NvmeofGatewayGroupFilterComponent } from '../nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
 
 const mockNamespaces = [
   {
@@ -31,16 +33,36 @@ const mockNamespaces = [
   }
 ];
 
+const mockGroups = [
+  [
+    {
+      service_name: 'nvmeof.rbd.g1',
+      service_type: 'nvmeof',
+      unmanaged: false,
+      spec: {
+        group: 'g1'
+      }
+    }
+  ],
+  1
+];
+
+const mockFormattedGwGroups = [
+  {
+    content: 'g1'
+  }
+];
+
 class MockNvmeOfService {
   gatewayGroupsResponse: any = [[{ id: 'g1' }]];
   namespacesResponse: any = { namespaces: mockNamespaces };
 
   listGatewayGroups() {
-    return of(this.gatewayGroupsResponse);
+    return of(mockGroups);
   }
 
   formatGwGroupsList(_response: any) {
-    return [{ content: 'g1', selected: false }];
+    return mockFormattedGwGroups;
   }
 
   listNamespaces(_group?: string) {
@@ -63,33 +85,55 @@ class MockTaskWrapperService {}
 describe('NvmeofNamespacesListComponent', () => {
   let component: NvmeofNamespacesListComponent;
   let fixture: ComponentFixture<NvmeofNamespacesListComponent>;
-
+  let queryParams$: BehaviorSubject<Record<string, string>>;
   let modalService: MockModalCdsService;
   let nvmeofService: MockNvmeOfService;
+  const activatedRouteMock = {
+    queryParams: null as any,
+    snapshot: { queryParams: {} as Record<string, string> }
+  };
 
   beforeEach(async () => {
-    const nvmeofStateServiceMock = {
-      refresh$: new Subject<void>(),
-      requestRefresh: jest.fn()
-    };
+    const refresh$ = new Subject<void>();
+    queryParams$ = new BehaviorSubject<Record<string, string>>({});
+    activatedRouteMock.queryParams = queryParams$.asObservable();
+    activatedRouteMock.snapshot.queryParams = queryParams$.value;
 
     await TestBed.configureTestingModule({
       declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent],
-      imports: [HttpClientModule, RouterTestingModule, SharedModule],
+      imports: [
+        HttpClientModule,
+        RouterTestingModule,
+        SharedModule,
+        NvmeofGatewayGroupFilterComponent
+      ],
       providers: [
         { provide: NvmeofService, useClass: MockNvmeOfService },
         { provide: AuthStorageService, useClass: MockAuthStorageService },
         { provide: ModalCdsService, useClass: MockModalCdsService },
         { provide: TaskWrapperService, useClass: MockTaskWrapperService },
-        { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
+        { provide: ActivatedRoute, useValue: activatedRouteMock },
+        {
+          provide: NvmeofStateService,
+          useValue: { refresh$: refresh$.asObservable(), requestRefresh: jest.fn() }
+        }
       ],
-      schemas: [NO_ERRORS_SCHEMA]
+      schemas: [CUSTOM_ELEMENTS_SCHEMA]
     }).compileComponents();
 
+    const router = TestBed.inject(Router);
+    jest.spyOn(router, 'navigate').mockImplementation((_commands, extras?) => {
+      const group = extras?.queryParams?.['group'];
+      const params = group ? { group: String(group) } : {};
+      activatedRouteMock.snapshot.queryParams = params;
+      queryParams$.next(params);
+      return Promise.resolve(true);
+    });
+
     fixture = TestBed.createComponent(NvmeofNamespacesListComponent);
     component = fixture.componentInstance;
-    component.ngOnInit();
     component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436';
+    component.ngOnInit();
     fixture.detectChanges();
     modalService = TestBed.inject(ModalCdsService) as any;
     nvmeofService = TestBed.inject(NvmeofService) as any;
@@ -100,7 +144,7 @@ describe('NvmeofNamespacesListComponent', () => {
   });
 
   it('should retrieve namespaces', (done) => {
-    component.group = 'g1';
+    component.groupHandler.group = 'g1';
     component.namespaces$.pipe(take(1)).subscribe((namespaces) => {
       expect(namespaces).toEqual(
         mockNamespaces.map((ns) => ({
@@ -131,7 +175,7 @@ describe('NvmeofNamespacesListComponent', () => {
   });
 
   it('should deduplicate namespaces by nsid and subsystem nqn', (done) => {
-    component.group = 'g1';
+    component.groupHandler.group = 'g1';
     nvmeofService.namespacesResponse = {
       namespaces: [
         { nsid: 1, ns_subsystem_nqn: 'sub1' },
@@ -140,7 +184,7 @@ describe('NvmeofNamespacesListComponent', () => {
       ]
     };
 
-    component.namespaces$.pipe(take(1)).subscribe((namespaces) => {
+    component.namespaces$.pipe(skip(1), take(1)).subscribe((namespaces) => {
       expect(namespaces).toEqual([
         { nsid: 1, ns_subsystem_nqn: 'sub1', unique_id: '1_sub1' },
         { nsid: 1, ns_subsystem_nqn: 'sub2', unique_id: '1_sub2' }
index 3217bc23cd8d92e962ea51faefb83a785103bd03..71b05f17cde96cd862990e45a43f978d05215ac9 100644 (file)
@@ -7,6 +7,7 @@ import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impa
 import { Icons } from '~/app/shared/enum/icons.enum';
 
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { FinishedTask } from '~/app/shared/models/finished-task';
 import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
@@ -17,22 +18,25 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofStateService } from '../nvmeof-state.service';
-import { BehaviorSubject, Observable, of, Subject } from 'rxjs';
-import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { GatewayGroupQueryHandlerService } from '../gateway-group-query-handler.service';
+import { BehaviorSubject, Observable, forkJoin, of, Subject } from 'rxjs';
+import { catchError, finalize, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
 
 @Component({
   selector: 'cd-nvmeof-namespaces-list',
   templateUrl: './nvmeof-namespaces-list.component.html',
   styleUrls: ['./nvmeof-namespaces-list.component.scss'],
-  standalone: false
+  standalone: false,
+  providers: [GatewayGroupQueryHandlerService]
 })
 export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
   @Input()
   subsystemNQN: string;
-  @Input()
-  group: string;
   @ViewChild('deleteTpl', { static: true })
   deleteTpl: TemplateRef<any>;
+  @ViewChild(TableComponent)
+  table: TableComponent;
   namespacesColumns: any;
   tableActions: CdTableAction[];
   selection = new CdTableSelection();
@@ -52,12 +56,17 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
     private taskWrapper: TaskWrapperService,
     private nvmeofService: NvmeofService,
     private dimlessBinaryPipe: DimlessBinaryPipe,
-    private nvmeofStateService: NvmeofStateService
+    private nvmeofStateService: NvmeofStateService,
+    public groupHandler: GatewayGroupQueryHandlerService
   ) {
     this.permission = this.authStorageService.getPermissions().nvmeof;
   }
 
   ngOnInit() {
+    this.groupHandler.init();
+    this.groupHandler.dataRefresh$
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(() => this.listNamespaces());
     this.namespacesColumns = [
       {
         name: $localize`Namespace ID`,
@@ -89,13 +98,13 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
         click: () => {
           this.router.navigate(['block/nvmeof/namespaces/create'], {
             queryParams: {
-              group: this.group,
+              group: this.groupHandler.group,
               subsystem_nqn: this.subsystemNQN
             }
           });
         },
         canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
-        disable: () => !this.group
+        disable: () => !this.groupHandler.group
       },
       {
         name: $localize`Expand`,
@@ -114,7 +123,7 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
               ],
               {
                 relativeTo: this.route,
-                queryParams: { group: this.group },
+                queryParams: { group: this.groupHandler.group },
                 queryParamsHandling: 'merge'
               }
             );
@@ -131,29 +140,22 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
 
     this.namespaces$ = this.namespaceSubject.pipe(
       switchMap(() => {
-        if (!this.group) {
-          return of([]);
+        if (!this.groupHandler.group) {
+          if (this.groupHandler.groupSelectionCleared) {
+            return of([]);
+          }
+          return this.fetchAllGroupsNamespaces();
         }
-        return this.nvmeofService.listNamespaces(this.group).pipe(
-          map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) => {
-            const namespaces = Array.isArray(res) ? res : res.namespaces || [];
-            // Deduplicate by nsid + subsystem NQN (API with wildcard can return duplicates per gateway)
-            const seen = new Set<string>();
-            return namespaces
-              .filter((ns) => {
-                const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
-                if (seen.has(key)) return false;
-                seen.add(key);
-                return true;
-              })
-              .map((ns) => ({
-                ...ns,
-                unique_id: `${ns.nsid}_${ns['ns_subsystem_nqn']}`
-              }));
-          }),
+        return this.nvmeofService.listNamespaces(this.groupHandler.group).pipe(
+          map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) =>
+            this.normalizeAndDedup(res)
+          ),
           catchError(() => of([]))
         );
       }),
+      tap(() => this.setTableLoading(false)),
+      finalize(() => this.setTableLoading(false)),
+      shareReplay({ bufferSize: 1, refCount: true }),
       takeUntil(this.destroy$)
     );
     this.nvmeofStateService.refresh$
@@ -161,19 +163,85 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
       .subscribe(() => this.fetchData());
   }
 
-  onGroupChange(group: string | null): void {
-    this.group = group;
-    this.namespaceSubject.next();
+  private normalizeAndDedup(
+    res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }
+  ): (NvmeofSubsystemNamespace & { unique_id: string })[] {
+    const namespaces = Array.isArray(res) ? res : res.namespaces || [];
+    const seen = new Set<string>();
+    return namespaces
+      .filter((ns) => {
+        const key = `${ns.nsid}_${ns['ns_subsystem_nqn']}`;
+        if (seen.has(key)) return false;
+        seen.add(key);
+        return true;
+      })
+      .map((ns) => ({ ...ns, unique_id: `${ns.nsid}_${ns['ns_subsystem_nqn']}` }));
+  }
+
+  private fetchAllGroupsNamespaces() {
+    return this.nvmeofService.listGatewayGroups().pipe(
+      map((gatewayGroups: CephServiceSpec[][]) => this.extractValidGroups(gatewayGroups)),
+      switchMap((groups) => this.fetchNamespacesForGroups(groups)),
+      catchError(() => of([]))
+    );
+  }
+
+  private extractValidGroups(gatewayGroups: CephServiceSpec[][]): CephServiceSpec[] {
+    const firstItem = gatewayGroups?.[0];
+    const groups = Array.isArray(firstItem) ? firstItem : [];
+    return groups.filter((g) => g?.spec?.group);
+  }
+
+  private fetchNamespacesForGroups(
+    groups: CephServiceSpec[]
+  ): Observable<(NvmeofSubsystemNamespace & { unique_id: string })[]> {
+    if (groups.length === 0) return of([]);
+    return forkJoin(groups.map((g) => this.fetchNamespacesForGroup(g.spec.group))).pipe(
+      map((results) => this.deduplicateAcrossGroups(results.flat()))
+    );
+  }
+
+  private fetchNamespacesForGroup(
+    groupName: string
+  ): Observable<(NvmeofSubsystemNamespace & { unique_id: string })[]> {
+    return this.nvmeofService.listNamespaces(groupName).pipe(
+      map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) =>
+        this.normalizeAndDedup(res)
+      ),
+      catchError(() => of([]))
+    );
+  }
+
+  private deduplicateAcrossGroups(
+    namespaces: (NvmeofSubsystemNamespace & { unique_id: string })[]
+  ): (NvmeofSubsystemNamespace & { unique_id: string })[] {
+    const seen = new Set<string>();
+    return namespaces.filter((ns) => {
+      if (seen.has(ns.unique_id)) return false;
+      seen.add(ns.unique_id);
+      return true;
+    });
   }
 
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
   }
 
-  fetchData() {
+  listNamespaces() {
+    this.setTableLoading(true);
     this.namespaceSubject.next();
   }
 
+  fetchData() {
+    this.listNamespaces();
+  }
+
+  private setTableLoading(loading: boolean): void {
+    if (this.table) {
+      this.table.loadingIndicator = loading;
+    }
+  }
+
   deleteNamespaceModal() {
     const namespace = this.selection.first();
     const subsystemNqn = namespace.ns_subsystem_nqn;
@@ -193,7 +261,11 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
               nqn: subsystemNqn,
               nsid: namespace.nsid
             }),
-            call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group)
+            call: this.nvmeofService.deleteNamespace(
+              subsystemNqn,
+              namespace.nsid,
+              this.groupHandler.group
+            )
           })
           .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() }))
     });
index d4fb4f1b491557ebc290b2588ffc5b6d22fc1594..5592a4aa1073c5c09855e69fd7480616fc3b3871 100644 (file)
@@ -30,7 +30,7 @@
         [description]="cards.subsystem.description"
         [isConfigured]="hasSubsystems"
         [successMessage]="cards.subsystem.successMessage"
-        [infoMessage]="hasGatewayGroups ? cards.subsystem.infoMessage : gatewayPendingMessage">
+        [infoMessage]="cards.subsystem.infoMessage">
       </cd-setup-step-card>
     </div>
 
@@ -41,7 +41,7 @@
         [description]="cards.namespace.description"
         [isConfigured]="hasNamespaces"
         [successMessage]="cards.namespace.successMessage"
-        [infoMessage]="hasGatewayGroups ? cards.namespace.infoMessage : gatewayPendingMessage">
+        [infoMessage]="cards.namespace.infoMessage">
       </cd-setup-step-card>
     </div>
   </div>
@@ -54,4 +54,4 @@
        i18n>Configuration complete. View status →</a>
   </div>
   }
-</cd-productive-card>
+</cd-productive-card>
\ No newline at end of file
index a41e9e24f0e2dd1be10e834606fdb10e82eaa6e2..b0e7044403df515546355ef32e12689c67e78e8a 100644 (file)
@@ -40,6 +40,17 @@ describe('NvmeofSetupCardsComponent', () => {
     expect(link).toBeTruthy();
   });
 
+  it('should not emit viewStatus from the disabled completion link', () => {
+    component.isAllConfigured = true;
+    fixture.detectChanges();
+
+    const emitSpy = jest.spyOn(component.viewStatus, 'emit');
+    const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion a');
+    link.click();
+
+    expect(emitSpy).not.toHaveBeenCalled();
+  });
+
   describe('setup state', () => {
     const getStepCards = () =>
       fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card');
@@ -79,7 +90,7 @@ describe('NvmeofSetupCardsComponent', () => {
     });
   });
 
-  it('should display "No gateway configured yet." on step 2 and 3 when hasGatewayGroups is false', () => {
+  it('should keep subsystem and namespace info messages when hasGatewayGroups is false', () => {
     component.hasGatewayGroups = false;
     component.hasSubsystems = false;
     component.hasNamespaces = false;
@@ -89,8 +100,8 @@ describe('NvmeofSetupCardsComponent', () => {
     const subsystemCard = cardElements[1].componentInstance;
     const namespaceCard = cardElements[2].componentInstance;
 
-    expect(subsystemCard.statusMessage).toBe('No gateway configured yet.');
-    expect(namespaceCard.statusMessage).toBe('No gateway configured yet.');
+    expect(subsystemCard.statusMessage).toBe('No subsystem configured for this cluster yet.');
+    expect(namespaceCard.statusMessage).toBe('No namespace allocated or mapped yet.');
   });
 
   it('should display original info messages when hasGatewayGroups is true', () => {
@@ -154,7 +165,11 @@ describe('NvmeofSetupCardsComponent', () => {
     expect(getCards()[0].componentInstance.statusMessage).toBe(
       'No gateway groups configured for this cluster yet.'
     );
-    expect(getCards()[1].componentInstance.statusMessage).toBe('No gateway configured yet.');
-    expect(getCards()[2].componentInstance.statusMessage).toBe('No gateway configured yet.');
+    expect(getCards()[1].componentInstance.statusMessage).toBe(
+      'No subsystem configured for this cluster yet.'
+    );
+    expect(getCards()[2].componentInstance.statusMessage).toBe(
+      'No namespace allocated or mapped yet.'
+    );
   });
 });
index 9dc9100fa90446c7d6f9dc949b83217acdff74cf..dda0027a22b15334e7d278fa989348534a66e588 100644 (file)
@@ -1,5 +1,5 @@
 import { CommonModule } from '@angular/common';
-import { Component, Input, ViewEncapsulation } from '@angular/core';
+import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
 import { RouterModule } from '@angular/router';
 import { LayoutModule, LayerModule, LinkModule, TilesModule } from 'carbon-components-angular';
 import { ProductiveCardComponent } from '~/app/shared/components/productive-card/productive-card.component';
@@ -27,8 +27,7 @@ export class NvmeofSetupCardsComponent {
   @Input() hasSubsystems = false;
   @Input() hasNamespaces = false;
   @Input() isAllConfigured = false;
-
-  readonly gatewayPendingMessage = $localize`No gateway configured yet.`;
+  @Output() viewStatus = new EventEmitter<void>();
 
   readonly cards = {
     gateway: {
index 57fa67a8b896b226615a1ab6ff46ad61bdc910df..9eea105110fe1261d114932a47f202d975c4c015 100644 (file)
@@ -2,7 +2,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { RouterTestingModule } from '@angular/router/testing';
 import { ActivatedRoute } from '@angular/router';
-import { of, Subject } from 'rxjs';
+import { Subject, of } from 'rxjs';
 
 import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list.component';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
@@ -43,11 +43,7 @@ describe('NvmeofSubsystemNamespacesListComponent', () => {
   }
 
   beforeEach(async () => {
-    const nvmeofStateServiceMock = {
-      refresh$: new Subject<void>(),
-      requestRefresh: jest.fn()
-    };
-
+    const refresh$ = new Subject<void>();
     await TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemNamespacesListComponent],
       imports: [HttpClientTestingModule, RouterTestingModule, SharedModule],
@@ -68,7 +64,10 @@ describe('NvmeofSubsystemNamespacesListComponent', () => {
           }
         },
         { provide: AuthStorageService, useClass: MockAuthStorageService },
-        { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
+        {
+          provide: NvmeofStateService,
+          useValue: { refresh$: refresh$.asObservable(), requestRefresh: jest.fn() }
+        }
       ]
     }).compileComponents();
   });
index aef098c0d865378a9597686299b50bf217a969b2..3b2e08dbdf88d8db06fe78a12c4e69af6dc09fd2 100644 (file)
@@ -1,11 +1,10 @@
-<cd-nvmeof-gateway-group-filter
-  (groupChange)="onGroupChange($event)">
-</cd-nvmeof-gateway-group-filter>
-
+<div class="nvmeof-content-layout">
+  <div class="nvmeof-content-main">
 <ng-container *ngIf="subsystems$ | async as subsystems">
   <cd-table #table
             [data]="subsystems"
             [columns]="subsystemsColumns"
+            [compactSearchField]="true"
             columnMode="flex"
             selectionType="single"
             (updateSelection)="updateSelection($event)"
             emptyStateMessage="Subsystems group NVMe namespaces and manage host access. Create a subsystem to start mapping NVMe volumes to hosts."
             i18n-emptyStateMessage>
 
+    <div class="table-filter">
+      <cd-nvmeof-gateway-group-filter
+        (groupChange)="groupHandler.onGroupChange($event)">
+      </cd-nvmeof-gateway-group-filter>
+    </div>
+
     <div class="table-actions">
       <cd-table-actions [permission]="permissions.nvmeof"
                         [selection]="selection"
@@ -30,6 +35,8 @@
     </cd-nvmeof-subsystems-details>
   </cd-table>
 </ng-container>
+  </div>
+</div>
 
 <ng-template #customTableItemTemplate
              let-value="data.value"
index e62934ca8d44830170cb87820114cdee2d7dbd29..fa179366ffe62605d839452d0fc7e17b354d924c 100644 (file)
@@ -1,8 +1,8 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
 import { HttpClientModule } from '@angular/common/http';
 import { ActivatedRoute, Router } from '@angular/router';
-import { BehaviorSubject, of, Subject } from 'rxjs';
-import { skip, take } from 'rxjs/operators';
+import { BehaviorSubject, Subject, of } from 'rxjs';
+import { take } from 'rxjs/operators';
 import { RouterTestingModule } from '@angular/router/testing';
 import { SharedModule } from '~/app/shared/shared.module';
 
@@ -13,7 +13,6 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component';
 import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
 import { NvmeofGatewayGroupFilterComponent } from '../nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
-import { ComboBoxModule, GridModule } from 'carbon-components-angular';
 import { NvmeofStateService } from '../nvmeof-state.service';
 
 const mockSubsystems = [
@@ -30,6 +29,17 @@ const mockSubsystems = [
   }
 ];
 
+const mockGroups = [
+  [
+    {
+      service_name: 'nvmeof.default',
+      service_type: 'nvmeof',
+      service_id: 'default',
+      spec: { group: 'default' }
+    }
+  ]
+];
+
 class MockNvmeOfService {
   listGatewayGroups() {
     return of([
@@ -73,7 +83,6 @@ class MockTaskWrapperService {}
 describe('NvmeofSubsystemsComponent', () => {
   let component: NvmeofSubsystemsComponent;
   let fixture: ComponentFixture<NvmeofSubsystemsComponent>;
-  let nvmeofService: MockNvmeOfService;
   let queryParams$: BehaviorSubject<Record<string, string>>;
   const activatedRouteMock = {
     queryParams: null as any,
@@ -81,23 +90,17 @@ describe('NvmeofSubsystemsComponent', () => {
   };
 
   beforeEach(async () => {
+    const refresh$ = new Subject<void>();
     queryParams$ = new BehaviorSubject<Record<string, string>>({});
     activatedRouteMock.queryParams = queryParams$.asObservable();
     activatedRouteMock.snapshot.queryParams = queryParams$.value;
 
-    const nvmeofStateServiceMock = {
-      refresh$: new Subject<void>(),
-      requestRefresh: jest.fn()
-    };
-
     await TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemsComponent, NvmeofSubsystemsDetailsComponent],
       imports: [
         HttpClientModule,
         RouterTestingModule,
         SharedModule,
-        ComboBoxModule,
-        GridModule,
         NvmeofGatewayGroupFilterComponent
       ],
       providers: [
@@ -106,7 +109,10 @@ describe('NvmeofSubsystemsComponent', () => {
         { provide: ModalCdsService, useClass: MockModalService },
         { provide: TaskWrapperService, useClass: MockTaskWrapperService },
         { provide: ActivatedRoute, useValue: activatedRouteMock },
-        { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
+        {
+          provide: NvmeofStateService,
+          useValue: { refresh$: refresh$.asObservable(), requestRefresh: jest.fn() }
+        }
       ]
     }).compileComponents();
 
@@ -121,7 +127,6 @@ describe('NvmeofSubsystemsComponent', () => {
 
     fixture = TestBed.createComponent(NvmeofSubsystemsComponent);
     component = fixture.componentInstance;
-    nvmeofService = TestBed.inject(NvmeofService) as any;
     component.ngOnInit();
     fixture.detectChanges();
   });
@@ -133,39 +138,35 @@ describe('NvmeofSubsystemsComponent', () => {
   it('should retrieve subsystems', (done) => {
     const expected = mockSubsystems.map((s) => ({
       ...s,
-      gw_group: 'default',
+      gw_group: component.groupHandler.group,
       auth: 'No authentication',
       initiator_count: 0
     }));
-    component.onGroupChange('default');
-    component.subsystems$.pipe(skip(1), take(1)).subscribe((subsystems) => {
+    component.subsystems$.pipe(take(1)).subscribe((subsystems) => {
       expect(subsystems).toEqual(expected);
       done();
     });
-    component.fetchData();
+    component.getSubsystems();
   });
 
-  it('should not fetch subsystems when group is not selected', (done) => {
-    const listSubsystemsSpy = jest.spyOn(nvmeofService, 'listSubsystems');
-    component.group = null;
-    component.fetchData();
+  it('should set first group as default initially', () => {
+    expect(component.groupHandler.group).toBe(mockGroups[0][0].spec.group);
+  });
 
+  it('should show subsystems across groups when dropdown selection is cleared', (done) => {
+    component.groupHandler.onGroupClear();
     component.subsystems$.pipe(take(1)).subscribe((subsystems) => {
-      expect(subsystems).toEqual([]);
-      expect(listSubsystemsSpy).not.toHaveBeenCalled();
+      expect(subsystems.length).toBeGreaterThan(0);
       done();
     });
-  });
-
-  it('should set first group as default initially', () => {
-    expect(component.group).toBe('default');
+    component.getSubsystems();
   });
 
   it('should clear selected group and stop fetching subsystems', () => {
-    component.group = 'default';
+    component.groupHandler.group = 'default';
 
-    component.onGroupChange(null);
+    component.groupHandler.onGroupChange(null);
 
-    expect(component.group).toBeNull();
+    expect(component.groupHandler.group).toBeNull();
   });
 });
index cc2467f06526241273e931b50ddd47ba4e136a7a..de0e7ebd4ccb76f043fe5d3be15d6b21795fb361 100644 (file)
@@ -13,6 +13,7 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
 import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
 import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
 
 import { Icons } from '~/app/shared/enum/icons.enum';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
@@ -21,9 +22,11 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
 import { NvmeofStateService } from '../nvmeof-state.service';
+import { GatewayGroupQueryHandlerService } from '../gateway-group-query-handler.service';
 import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
-import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
+import { catchError, finalize, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators';
 import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { CephServiceSpec } from '~/app/shared/models/service.interface';
 
 const BASE_URL = 'block/nvmeof/subsystems';
 
@@ -31,7 +34,8 @@ const BASE_URL = 'block/nvmeof/subsystems';
   selector: 'cd-nvmeof-subsystems',
   templateUrl: './nvmeof-subsystems.component.html',
   styleUrls: ['./nvmeof-subsystems.component.scss'],
-  standalone: false
+  standalone: false,
+  providers: [GatewayGroupQueryHandlerService]
 })
 export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit, OnDestroy {
   @ViewChild('authenticationTpl', { static: true })
@@ -46,6 +50,9 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
   @ViewChild('customTableItemTemplate', { static: true })
   customTableItemTemplate: TemplateRef<any>;
 
+  @ViewChild(TableComponent)
+  table: TableComponent;
+
   subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = [];
   pendingNqn: string = null;
   subsystemsColumns: any;
@@ -54,9 +61,9 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
   tableActions: CdTableAction[];
   subsystemDetails: any[];
   context: CdTableFetchDataContext;
-  group: string = null;
   authType = NvmeofSubsystemAuthType;
   subsystems$: Observable<(NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[]>;
+
   private subsystemSubject = new BehaviorSubject<void>(undefined);
 
   private destroy$ = new Subject<void>();
@@ -68,13 +75,18 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     private router: Router,
     private modalService: ModalCdsService,
     private taskWrapper: TaskWrapperService,
-    private nvmeofStateService: NvmeofStateService
+    private nvmeofStateService: NvmeofStateService,
+    public groupHandler: GatewayGroupQueryHandlerService
   ) {
     super();
     this.permissions = this.authStorageService.getPermissions();
   }
 
   ngOnInit() {
+    this.groupHandler.init();
+    this.groupHandler.dataRefresh$
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(() => this.getSubsystems());
     this.subsystemsColumns = [
       {
         name: $localize`Subsystem NQN`,
@@ -109,10 +121,10 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
         icon: Icons.add,
         click: () =>
           this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }], {
-            queryParams: { group: this.group }
+            queryParams: { group: this.groupHandler.group }
           }),
         canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
-        disable: () => !this.group
+        disable: () => !this.groupHandler.group
       },
       {
         name: this.actionLabels.DELETE,
@@ -124,14 +136,19 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
 
     this.subsystems$ = this.subsystemSubject.pipe(
       switchMap(() => {
-        if (!this.group) {
-          return of([]);
+        if (!this.groupHandler.group) {
+          if (this.groupHandler.groupSelectionCleared) {
+            return of([]);
+          }
+          return this.fetchAllGroupsSubsystems();
         }
-        return this.nvmeofService.listSubsystems(this.group).pipe(
-          switchMap((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => {
-            const subs = Array.isArray(subsystems) ? subsystems : [subsystems];
+        return this.nvmeofService.listSubsystems(this.groupHandler.group).pipe(
+          switchMap((subsystems: any) => {
+            const subs: NvmeofSubsystem[] = Array.isArray(subsystems) ? subsystems : [subsystems];
             if (subs.length === 0) return of([]);
-            return forkJoin(subs.map((sub) => this.enrichSubsystemWithInitiators(sub)));
+            return forkJoin(
+              subs.map((sub) => this.enrichSubsystemForGroup(sub, this.groupHandler.group))
+            );
           }),
           catchError((error) => {
             this.handleError(error);
@@ -141,7 +158,10 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
       }),
       tap((subs) => {
         this.subsystems = subs;
+        this.setTableLoading(false);
       }),
+      finalize(() => this.setTableLoading(false)),
+      shareReplay({ bufferSize: 1, refCount: true }),
       takeUntil(this.destroy$)
     );
     this.nvmeofStateService.refresh$
@@ -149,8 +169,8 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
       .subscribe(() => this.fetchData());
   }
 
-  onGroupChange(group: string | null): void {
-    this.group = group;
+  getSubsystems() {
+    this.setTableLoading(true);
     this.subsystemSubject.next();
   }
 
@@ -159,7 +179,13 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
   }
 
   fetchData() {
-    this.subsystemSubject.next();
+    this.getSubsystems();
+  }
+
+  private setTableLoading(loading: boolean): void {
+    if (this.table) {
+      this.table.loadingIndicator = loading;
+    }
   }
 
   deleteSubsystemModal() {
@@ -178,7 +204,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
         this.taskWrapper
           .wrapTaskAroundCall({
             task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }),
-            call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group)
+            call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.groupHandler.group)
           })
           .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() }))
     });
@@ -191,8 +217,8 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     this.context?.error?.(error);
   }
 
-  private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) {
-    return this.nvmeofService.getInitiators(sub.nqn, this.group).pipe(
+  private enrichSubsystemForGroup(sub: NvmeofSubsystem, group: string) {
+    return this.nvmeofService.getInitiators(sub.nqn, group).pipe(
       catchError(() => of([])),
       map((initiators: NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] }) => {
         let count = 0;
@@ -200,10 +226,9 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
         else if (initiators?.hosts && Array.isArray(initiators.hosts)) {
           count = initiators.hosts.length;
         }
-
         return {
           ...sub,
-          gw_group: this.group,
+          gw_group: group,
           initiator_count: count,
           auth: getSubsystemAuthStatus(sub, initiators)
         } as NvmeofSubsystem & { initiator_count?: number; auth?: string };
@@ -211,6 +236,40 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
     );
   }
 
+  private fetchAllGroupsSubsystems() {
+    return this.nvmeofService.listGatewayGroups().pipe(
+      map((gatewayGroups) => this.extractValidGroups(gatewayGroups)),
+      switchMap((groups) => this.fetchSubsystemsForGroups(groups)),
+      catchError(() => of([]))
+    );
+  }
+
+  private extractValidGroups(gatewayGroups: CephServiceSpec[][]): CephServiceSpec[] {
+    const firstItem = gatewayGroups?.[0];
+    const groups = Array.isArray(firstItem) ? firstItem : [];
+    return groups.filter((g) => g?.spec?.group);
+  }
+
+  private fetchSubsystemsForGroups(groups: CephServiceSpec[]): Observable<NvmeofSubsystem[]> {
+    if (groups.length === 0) return of([]);
+    return forkJoin(groups.map((g) => this.fetchSubsystemsForGroup(g.spec.group))).pipe(
+      map((results) => results.flat())
+    );
+  }
+
+  private fetchSubsystemsForGroup(groupName: string): Observable<NvmeofSubsystem[]> {
+    return this.nvmeofService.listSubsystems(groupName).pipe(
+      switchMap((subsystems) => this.enrichSubsystems(subsystems, groupName)),
+      catchError(() => of([]))
+    );
+  }
+
+  private enrichSubsystems(subsystems: any, groupName: string): Observable<NvmeofSubsystem[]> {
+    const subs = Array.isArray(subsystems) ? subsystems : [subsystems];
+    if (subs.length === 0) return of([]);
+    return forkJoin(subs.map((sub) => this.enrichSubsystemForGroup(sub, groupName)));
+  }
+
   ngOnDestroy() {
     this.destroy$.next();
     this.destroy$.complete();
index b97da033c23435372c6f24f65a7c70c61a5f247f..44e148377218593f4d8dd0420bfb94844d8f65fd 100644 (file)
@@ -1,7 +1,7 @@
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ActivatedRoute, Event as RouterEvent, NavigationEnd, Router } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
-import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { BehaviorSubject, Subject, of } from 'rxjs';
 
 import { TabsModule } from 'carbon-components-angular';
@@ -27,9 +27,13 @@ describe('NvmeofTabsComponent', () => {
   let refresh$: Subject<void>;
   let routerEvents$: Subject<RouterEvent>;
   let currentSetupState: SetupState;
+  let routerUrl = '/block/nvmeof/gateways';
 
   const setQueryParams = (params: any) => queryParams$.next(params);
   const emitRefresh = () => refresh$.next();
+  const setRouterUrl = (url: string) => {
+    routerUrl = url;
+  };
   const setSetupState = (state: SetupState) => {
     currentSetupState = state;
     nvmeofServiceSpy.fetchSetupState.mockReturnValue(of(currentSetupState));
@@ -38,9 +42,7 @@ describe('NvmeofTabsComponent', () => {
   beforeEach(async () => {
     queryParams$ = new BehaviorSubject<any>({ group: 'grp1' });
     refresh$ = new Subject<void>();
-    const nvmeofStateServiceMock = {
-      refresh$: refresh$.asObservable()
-    };
+    routerUrl = '/block/nvmeof/gateways';
     currentSetupState = { hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true };
     nvmeofServiceSpy = {
       fetchSetupState: jest.fn().mockImplementation(() => of(currentSetupState))
@@ -49,8 +51,8 @@ describe('NvmeofTabsComponent', () => {
     TestBed.configureTestingModule({
       declarations: [NvmeofTabsComponent],
       imports: [
-        RouterTestingModule,
         HttpClientTestingModule,
+        RouterTestingModule,
         SharedModule,
         TabsModule,
         NvmeofSetupCardsComponent
@@ -59,21 +61,38 @@ describe('NvmeofTabsComponent', () => {
         { provide: NvmeofService, useValue: nvmeofServiceSpy },
         { provide: ActivatedRoute, useValue: { queryParams: queryParams$.asObservable() } }
       ]
-    });
-    TestBed.overrideComponent(NvmeofTabsComponent, {
-      set: { providers: [{ provide: NvmeofStateService, useValue: nvmeofStateServiceMock }] }
-    });
-    await TestBed.compileComponents();
+    })
+      .overrideComponent(NvmeofTabsComponent, {
+        set: {
+          providers: [
+            {
+              provide: NvmeofStateService,
+              useValue: {
+                refresh$: refresh$.asObservable(),
+                requestRefresh: jest.fn()
+              }
+            }
+          ]
+        }
+      })
+      .compileComponents();
 
     fixture = TestBed.createComponent(NvmeofTabsComponent);
     component = fixture.componentInstance;
     router = TestBed.inject(Router);
     routerEvents$ = new Subject<RouterEvent>();
+    Object.defineProperty(router, 'events', {
+      configurable: true,
+      get: () => routerEvents$.asObservable()
+    });
     Object.defineProperty(router, 'url', {
-      get: () => '/block/nvmeof/gateways',
-      configurable: true
+      configurable: true,
+      get: () => routerUrl
+    });
+    Object.defineProperty(router, 'navigate', {
+      configurable: true,
+      value: jest.fn().mockResolvedValue(true)
     });
-    jest.spyOn(router, 'events', 'get').mockReturnValue(routerEvents$.asObservable());
   });
 
   it('should create', () => {
@@ -81,59 +100,54 @@ describe('NvmeofTabsComponent', () => {
   });
 
   it('should default activeTab to gateways', () => {
-    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
+    setRouterUrl('/block/nvmeof/gateways');
     component.ngOnInit();
     expect(component.activeTab).toBe(component.Tabs.gateways);
   });
 
   it('should set activeTab to subsystems when URL contains subsystems', () => {
-    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/subsystems');
+    setRouterUrl('/block/nvmeof/subsystems');
     component.ngOnInit();
     expect(component.activeTab).toBe(component.Tabs.subsystems);
   });
 
   it('should set activeTab to namespaces when URL contains namespaces', () => {
-    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces');
+    setRouterUrl('/block/nvmeof/namespaces');
     component.ngOnInit();
     expect(component.activeTab).toBe(component.Tabs.namespaces);
   });
 
   it('should fallback to gateways when URL does not match any tab', () => {
-    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/unknown');
+    setRouterUrl('/block/nvmeof/unknown');
     component.ngOnInit();
     expect(component.activeTab).toBe(component.Tabs.gateways);
   });
 
   it('should hide the shell on namespace create routes', () => {
-    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces/create');
+    setRouterUrl('/block/nvmeof/namespaces/create');
     component.ngOnInit();
     expect(component.showTabsShell).toBe(false);
   });
 
   it('should keep the shell visible on namespace list routes', () => {
-    jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/namespaces');
+    setRouterUrl('/block/nvmeof/namespaces');
     component.ngOnInit();
     expect(component.showTabsShell).toBe(true);
   });
 
   it('should keep the shell visible on list routes with a secondary outlet', () => {
-    jest
-      .spyOn(router, 'url', 'get')
-      .mockReturnValue('/block/nvmeof/subsystems(modal:create)?group=default');
+    setRouterUrl('/block/nvmeof/subsystems(modal:create)?group=default');
     component.ngOnInit();
     expect(component.showTabsShell).toBe(true);
   });
 
   it('should hide the shell when primary route is a create page with secondary outlet', () => {
-    jest
-      .spyOn(router, 'url', 'get')
-      .mockReturnValue('/block/nvmeof/subsystems/create(modal:create)?group=default');
+    setRouterUrl('/block/nvmeof/subsystems/create(modal:create)?group=default');
     component.ngOnInit();
     expect(component.showTabsShell).toBe(false);
   });
 
   it('should navigate to correct path on tab selection', () => {
-    spyOn(router, 'navigate');
     component.onSelected(component.Tabs.subsystems);
     expect(component.activeTab).toBe(component.Tabs.subsystems);
     expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'subsystems'], {
@@ -142,7 +156,6 @@ describe('NvmeofTabsComponent', () => {
   });
 
   it('should navigate to gateways on selecting gateways tab', () => {
-    spyOn(router, 'navigate');
     component.onSelected(component.Tabs.gateways);
     expect(component.activeTab).toBe(component.Tabs.gateways);
     expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'gateways'], {
@@ -151,7 +164,6 @@ describe('NvmeofTabsComponent', () => {
   });
 
   it('should navigate to namespaces on selecting namespaces tab', () => {
-    spyOn(router, 'navigate');
     component.onSelected(component.Tabs.namespaces);
     expect(component.activeTab).toBe(component.Tabs.namespaces);
     expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'namespaces'], {
@@ -181,6 +193,7 @@ describe('NvmeofTabsComponent', () => {
       expect(component.hasSubsystems).toBe(true);
       expect(component.hasNamespaces).toBe(true);
       expect(component.isAllConfigured).toBe(true);
+      expect(component.showSetupCards).toBe(true);
     });
 
     it('scenario: no gateway groups — all steps pending', () => {
@@ -203,6 +216,15 @@ describe('NvmeofTabsComponent', () => {
       expect(component.isAllConfigured).toBe(false);
     });
 
+    it('scenario: selected gateway group does not exist — setup still reflects all groups', () => {
+      component.ngOnInit();
+      setQueryParams({ group: 'grp-other' });
+      expect(component.hasGatewayGroups).toBe(true);
+      expect(component.hasSubsystems).toBe(true);
+      expect(component.hasNamespaces).toBe(true);
+      expect(component.isAllConfigured).toBe(true);
+    });
+
     it('scenario: no subsystems in object response across all groups — step 1 complete', () => {
       setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false });
       component.ngOnInit();
@@ -351,8 +373,7 @@ describe('NvmeofTabsComponent', () => {
     });
 
     it('should render correct setup card messages after all gateway groups are removed', () => {
-      jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
-      setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true });
+      setRouterUrl('/block/nvmeof/gateways');
       component.ngOnInit();
 
       setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false });
@@ -365,12 +386,16 @@ describe('NvmeofTabsComponent', () => {
       expect(cardElements[0].componentInstance.statusMessage).toBe(
         'No gateway groups configured for this cluster yet.'
       );
-      expect(cardElements[1].componentInstance.statusMessage).toBe('No gateway configured yet.');
-      expect(cardElements[2].componentInstance.statusMessage).toBe('No gateway configured yet.');
+      expect(cardElements[1].componentInstance.statusMessage).toBe(
+        'No subsystem configured for this cluster yet.'
+      );
+      expect(cardElements[2].componentInstance.statusMessage).toBe(
+        'No namespace allocated or mapped yet.'
+      );
     });
 
     it('should render success setup card messages before gateway groups are removed', () => {
-      jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways');
+      setRouterUrl('/block/nvmeof/gateways');
       component.ngOnInit();
       emitRefresh();
       fixture.detectChanges();
index a42cd572a2c0a2ff559dbb9376d8eb91b0b413db..e8c933fc2fe1524863de8a79a7a6f12d69dbe075 100644 (file)
@@ -80,7 +80,7 @@ export class NvmeofTabsComponent implements OnInit, OnDestroy {
         this.hasSubsystems = hasSubsystems;
         this.hasNamespaces = hasNamespaces;
         this.isAllConfigured = hasGatewayGroups && hasSubsystems && hasNamespaces;
-        this.showSetupCards = !this.isAllConfigured;
+        this.showSetupCards = true;
       });
   }
 
index f76c642986f38a9edea39360efb4b64ff98946a2..feed57940b8c59cc5deb1a10dd3dff32c6b43287 100644 (file)
           }}
           @if (customFilter) {
           @for (filter of stagedCustomFilters; track filter.id; let i = $index) {
-            @if (typeof customFilter === 'string') {
+            @if (isCustomFilterLabel()) {
               <div cdsRow
                    class="cds-mt-4 cds--type-body-02">
                 Filter {{ customFilter }}:
index a261212f324d7271222ec8689bd5f659a9b41dcf..d44f5f053b45fefaac200d35486be4b844ef270a 100644 (file)
@@ -207,6 +207,10 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr
   @Input()
   customFilter: boolean | string = false;
 
+  isCustomFilterLabel(): boolean {
+    return typeof this.customFilter === 'string';
+  }
+
   @Input()
   status = new TableStatus();
 
@@ -283,6 +287,8 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr
 
   @Output()
   setExpandedRow = new EventEmitter();
+  @Input()
+  compactSearchField? = false;
 
   /**
    * This should be defined if you need access to the applied column filters.
index ebfc501f5842b3cbe96722cd9b84ec47fc1b3301..35786d29db314821155abab45a96bdbd7ebf783d 100644 (file)
@@ -241,3 +241,18 @@ input:-webkit-autofill:active {
 .popover {
   --bs-popover-zindex: 9999;
 }
+
+// Remove bottom border from NVMeoF gateway group filter combobox
+.nvmeof-gateway-group-filter__combo {
+  .cds--combo-box,
+  .cds--text-input,
+  .cds--list-box,
+  input[role='combobox'],
+  input {
+    border-bottom: none !important;
+  }
+
+  .cds--text-input__field-wrapper {
+    border-bottom: none !important;
+  }
+}