From b7708cd711f0e2c08985258076e03eb2209cace6 Mon Sep 17 00:00:00 2001 From: pujashahu Date: Tue, 2 Jun 2026 18:41:27 +0530 Subject: [PATCH] mgr/dashboard : Add gateway filtering support for subsystem and namespace lists Fixes: https://tracker.ceph.com/issues/77068 Signed-off-by: pujaoshahu (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 --- .../gateway-group-query-handler.service.ts | 153 ++++++++++++++++++ ...nvmeof-gateway-group-filter.component.html | 34 ++-- ...nvmeof-gateway-group-filter.component.scss | 43 +++++ ...eof-gateway-group-filter.component.spec.ts | 10 +- .../nvmeof-gateway-group-filter.component.ts | 131 ++++++--------- .../nvmeof-gateway-group.component.html | 43 ++--- .../nvmeof-gateway-group.component.spec.ts | 1 - .../nvmeof-gateway-group.component.ts | 11 +- .../nvmeof-namespaces-list.component.html | 15 +- .../nvmeof-namespaces-list.component.spec.ts | 78 +++++++-- .../nvmeof-namespaces-list.component.ts | 138 ++++++++++++---- .../nvmeof-setup-cards.component.html | 6 +- .../nvmeof-setup-cards.component.spec.ts | 25 ++- .../nvmeof-setup-cards.component.ts | 5 +- ...ubsystem-namespaces-list.component.spec.ts | 13 +- .../nvmeof-subsystems.component.html | 15 +- .../nvmeof-subsystems.component.spec.ts | 61 +++---- .../nvmeof-subsystems.component.ts | 99 +++++++++--- .../nvmeof-tabs/nvmeof-tabs.component.spec.ts | 91 +++++++---- .../nvmeof-tabs/nvmeof-tabs.component.ts | 2 +- .../datatable/table/table.component.html | 2 +- .../shared/datatable/table/table.component.ts | 6 + .../mgr/dashboard/frontend/src/styles.scss | 15 ++ 23 files changed, 708 insertions(+), 289 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/gateway-group-query-handler.service.ts 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 index 00000000000..51a30d5c687 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/gateway-group-query-handler.service.ts @@ -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(); + private destroy$ = new Subject(); + + 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(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html index c04056a900d..1d2b25733f2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html @@ -1,22 +1,14 @@ -
-
-
- - - -
-
+
+ Gateway group: + + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss index e69de29bb2d..efb98006402 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.scss @@ -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; + } + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts index 2324b2ce161..4e16ebd11a9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts @@ -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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts index 39dd35faa3a..376aa4fe9eb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts @@ -1,115 +1,88 @@ -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(); items: GroupsComboboxItem[] = []; - disabled = false; - placeholder = DEFAULT_PLACEHOLDER; + placeholder: string = ''; + disabled: boolean = false; private destroy$ = new Subject(); 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(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html index 8d4007806bc..285a5705538 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.html @@ -1,27 +1,28 @@ - - - -
- +
+ + + -
- - + + +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts index dcaced4aaaa..93055deeddf 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.spec.ts @@ -22,7 +22,6 @@ describe('NvmeofGatewayGroupComponent', () => { listGatewayGroups: jest.fn().mockReturnValue(of([])), listSubsystems: jest.fn().mockReturnValue(of([])) }; - const nvmeofStateServiceMock = { refresh$: new Subject(), requestRefresh: jest.fn() diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts index b59f235e8af..b590f6b1048 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts @@ -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; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html index 38c5c2fc78c..c8db104d65a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html @@ -1,9 +1,8 @@ - - - +
+
+
+ + +
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts index 945c99ea040..e49ba58057f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts @@ -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; - + let queryParams$: BehaviorSubject>; let modalService: MockModalCdsService; let nvmeofService: MockNvmeOfService; + const activatedRouteMock = { + queryParams: null as any, + snapshot: { queryParams: {} as Record } + }; beforeEach(async () => { - const nvmeofStateServiceMock = { - refresh$: new Subject(), - requestRefresh: jest.fn() - }; + const refresh$ = new Subject(); + queryParams$ = new BehaviorSubject>({}); + 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' } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts index 3217bc23cd8..71b05f17cde 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts @@ -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; + @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(); - 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(); + 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(); + 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() })) }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html index d4fb4f1b491..5592a4aa107 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html @@ -30,7 +30,7 @@ [description]="cards.subsystem.description" [isConfigured]="hasSubsystems" [successMessage]="cards.subsystem.successMessage" - [infoMessage]="hasGatewayGroups ? cards.subsystem.infoMessage : gatewayPendingMessage"> + [infoMessage]="cards.subsystem.infoMessage">
@@ -41,7 +41,7 @@ [description]="cards.namespace.description" [isConfigured]="hasNamespaces" [successMessage]="cards.namespace.successMessage" - [infoMessage]="hasGatewayGroups ? cards.namespace.infoMessage : gatewayPendingMessage"> + [infoMessage]="cards.namespace.infoMessage"> @@ -54,4 +54,4 @@ i18n>Configuration complete. View status → } - + \ No newline at end of file diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts index a41e9e24f0e..b0e7044403d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts @@ -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.' + ); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts index 9dc9100fa90..dda0027a22b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts @@ -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(); readonly cards = { gateway: { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts index 57fa67a8b89..9eea105110f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.spec.ts @@ -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(), - requestRefresh: jest.fn() - }; - + const refresh$ = new Subject(); 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(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html index aef098c0d86..3b2e08dbdf8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html @@ -1,11 +1,10 @@ - - - +
+
+
+ + +
+
+
+
{ let component: NvmeofSubsystemsComponent; let fixture: ComponentFixture; - let nvmeofService: MockNvmeOfService; let queryParams$: BehaviorSubject>; const activatedRouteMock = { queryParams: null as any, @@ -81,23 +90,17 @@ describe('NvmeofSubsystemsComponent', () => { }; beforeEach(async () => { + const refresh$ = new Subject(); queryParams$ = new BehaviorSubject>({}); activatedRouteMock.queryParams = queryParams$.asObservable(); activatedRouteMock.snapshot.queryParams = queryParams$.value; - const nvmeofStateServiceMock = { - refresh$: new Subject(), - 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(); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts index cc2467f0652..de0e7ebd4cc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts @@ -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; + @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(undefined); private destroy$ = new Subject(); @@ -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 { + 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 { + return this.nvmeofService.listSubsystems(groupName).pipe( + switchMap((subsystems) => this.enrichSubsystems(subsystems, groupName)), + catchError(() => of([])) + ); + } + + private enrichSubsystems(subsystems: any, groupName: string): Observable { + 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(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts index b97da033c23..44e14837721 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts @@ -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; let routerEvents$: Subject; 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({ group: 'grp1' }); refresh$ = new Subject(); - 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(); + 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(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts index a42cd572a2c..e8c933fc2fe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts @@ -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; }); } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html index f76c642986f..feed57940b8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.html @@ -127,7 +127,7 @@ }} @if (customFilter) { @for (filter of stagedCustomFilters; track filter.id; let i = $index) { - @if (typeof customFilter === 'string') { + @if (isCustomFilterLabel()) {
Filter {{ customFilter }}: diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts index a261212f324..d44f5f053b4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts @@ -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. diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index ebfc501f584..35786d29db3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -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; + } +} -- 2.47.3