From: pujashahu Date: Tue, 2 Jun 2026 18:35:45 +0000 (+0530) Subject: mgr/dashboard: NVMe Onboarding Setup Cards X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=6182e67ea44159e54bdf8c3af6f7648b562bebdd;p=ceph.git mgr/dashboard: NVMe Onboarding Setup Cards Fixes: https://tracker.ceph.com/issues/77067 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index 3c5c8d9e7cc..135ec9134b4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -106,6 +106,8 @@ import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem import { NvmeofSubsystemPerformanceComponent } from './nvmeof-subsystem-performance/nvmeof-subsystem-performance.component'; import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component'; import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group/nvmeof-gateway-group-delete-guard-modal.component'; +import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards/nvmeof-setup-cards.component'; +import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component'; @NgModule({ imports: [ @@ -142,7 +144,9 @@ import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-gr ContainedListModule, SideNavModule, LayoutModule, - ThemeModule + ThemeModule, + NvmeofSetupCardsComponent, + NvmeofGatewayGroupFilterComponent ], declarations: [ RbdListComponent, @@ -337,6 +341,7 @@ const routes: Routes = [ { path: 'nvmeof', canActivate: [ModuleStatusGuardService], + component: NvmeofTabsComponent, data: { breadcrumbs: true, text: 'NVMe/TCP', 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 new file mode 100644 index 00000000000..d3b43ababa9 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.html @@ -0,0 +1,22 @@ +
+
+
+ + + +
+
+
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 new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..aef321896f5 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.spec.ts @@ -0,0 +1,111 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { NvmeofGatewayGroupFilterComponent } from './nvmeof-gateway-group-filter.component'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; + +const MOCK_GROUPS_RESPONSE = [ + [ + { spec: { group: 'grp1' }, service_name: 'nvmeof.grp1' }, + { spec: { group: 'grp2' }, service_name: 'nvmeof.grp2' } + ] +]; + +describe('NvmeofGatewayGroupFilterComponent', () => { + let fixture: ComponentFixture; + let component: NvmeofGatewayGroupFilterComponent; + let nvmeofService: Partial; + let routerSpy: Partial; + let activatedRoute: Partial; + + beforeEach(async () => { + nvmeofService = { + listGatewayGroups: jest.fn().mockReturnValue(of(MOCK_GROUPS_RESPONSE)), + formatGwGroupsList: jest.fn().mockReturnValue([{ content: 'grp1' }, { content: 'grp2' }]) + }; + + routerSpy = { + navigate: jest.fn().mockResolvedValue(true) + }; + + activatedRoute = { + snapshot: { + queryParams: {} + } as any + }; + + await TestBed.configureTestingModule({ + imports: [NvmeofGatewayGroupFilterComponent], + providers: [ + { provide: NvmeofService, useValue: nvmeofService }, + { provide: Router, useValue: routerSpy }, + { provide: ActivatedRoute, useValue: activatedRoute } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofGatewayGroupFilterComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should load gateway groups on init', () => { + fixture.detectChanges(); + expect(nvmeofService.listGatewayGroups).toHaveBeenCalled(); + expect(component.items.length).toBe(2); + }); + + it('should default to the first group when no group is in the URL', () => { + fixture.detectChanges(); + expect(routerSpy.navigate).toHaveBeenCalledWith( + [], + expect.objectContaining({ queryParams: { group: 'grp1' } }) + ); + }); + + it('should mark the URL group as selected when a group is already in the URL', () => { + (activatedRoute as any).snapshot.queryParams = { group: 'grp2' }; + fixture.detectChanges(); + const selected = component.items.find((i) => i.selected); + expect(selected?.content).toBe('grp2'); + }); + + it('should emit groupChange when a group is selected', () => { + fixture.detectChanges(); + const emitSpy = jest.spyOn(component.groupChange, 'emit'); + component.onSelected({ content: 'grp2' }); + expect(emitSpy).toHaveBeenCalledWith('grp2'); + }); + + it('should emit null groupChange when cleared', () => { + fixture.detectChanges(); + const emitSpy = jest.spyOn(component.groupChange, 'emit'); + component.onClear(); + expect(emitSpy).toHaveBeenCalledWith(null); + }); + + it('should disable and set placeholder when no groups are available', () => { + (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(of([[]])); + fixture.detectChanges(); + expect(component.disabled).toBe(true); + expect(component.placeholder).toBe('No groups available'); + }); + + it('should disable and set error placeholder on API error', () => { + const err = new Error('network error'); + (nvmeofService.listGatewayGroups as jest.Mock).mockReturnValue(throwError(() => err)); + fixture.detectChanges(); + expect(component.disabled).toBe(true); + expect(component.placeholder).toBe('Unable to fetch Gateway groups'); + }); + + it('should render a cds-combo-box', () => { + fixture.detectChanges(); + const combo = fixture.nativeElement.querySelector('cds-combo-box'); + expect(combo).toBeTruthy(); + }); +}); 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 new file mode 100644 index 00000000000..39dd35faa3a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component.ts @@ -0,0 +1,115 @@ +import { Component, EventEmitter, OnDestroy, OnInit, Output } from '@angular/core'; +import { ActivatedRoute, Router, RouterModule } from '@angular/router'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; +import { ComboBoxModule, GridModule, LayoutModule } 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] +}) +export class NvmeofGatewayGroupFilterComponent implements OnInit, OnDestroy { + @Output() groupChange = new EventEmitter(); + + items: GroupsComboboxItem[] = []; + disabled = false; + placeholder = DEFAULT_PLACEHOLDER; + + private destroy$ = new Subject(); + + constructor( + private nvmeofService: NvmeofService, + private route: ActivatedRoute, + private router: Router + ) {} + + 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) + }); + } + + private onGroupsLoaded(response: CephServiceSpec[][]): void { + if (response?.[0]?.length) { + this.items = this.nvmeofService.formatGwGroupsList(response); + } else { + this.items = []; + } + this.syncSelectionState(); + } + + private onGroupsError(error: any): void { + this.items = []; + this.disabled = true; + this.placeholder = $localize`Unable to fetch Gateway groups`; + if (error?.preventDefault) { + error.preventDefault(); + } + 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); + } +} 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 eae963ce641..40ffb638bfa 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,5 +1,3 @@ - - { let component: NvmeofGatewayGroupComponent; @@ -22,6 +24,11 @@ describe('NvmeofGatewayGroupComponent', () => { listSubsystems: jest.fn().mockReturnValue(of([])) }; + const nvmeofStateServiceMock = { + refresh$: new Subject(), + requestRefresh: jest.fn() + }; + await TestBed.configureTestingModule({ imports: [HttpClientModule, SharedModule, TabsModule, GridModule, ModalModule], declarations: [NvmeofGatewayGroupComponent], @@ -34,7 +41,8 @@ describe('NvmeofGatewayGroupComponent', () => { { provide: ModalCdsService, useValue: { show: jest.fn() } - } + }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ], schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); @@ -298,4 +306,39 @@ describe('NvmeofGatewayGroupComponent', () => { ); }); }); + + it('should refresh table and setup state after gateway group delete completes', () => { + const modalService = TestBed.inject(ModalCdsService); + const taskWrapperService = TestBed.inject(TaskWrapperService); + const nvmeofStateService = TestBed.inject(NvmeofStateService); + + jest.spyOn(modalService, 'show').mockImplementation(() => undefined); + jest.spyOn(taskWrapperService, 'wrapTaskAroundCall').mockReturnValue( + new Observable((observer) => { + observer.complete(); + }) + ); + + const refreshBtnSpy = jest.fn(); + component.table = { refreshBtn: refreshBtnSpy } as any; + const requestRefreshSpy = jest.spyOn(nvmeofStateService, 'requestRefresh'); + + component.selection = { + first: () => ({ + service_name: 'nvmeof.rbd.default', + spec: { group: 'default' }, + subSystemCount: 0 + }), + hasSelection: true + } as any; + + component.deleteGatewayGroupModal(); + + const submitActionObservable = (modalService.show as jest.Mock).mock.calls[0][1] + .submitActionObservable; + submitActionObservable().subscribe(); + + expect(refreshBtnSpy).toHaveBeenCalled(); + expect(requestRefreshSpy).toHaveBeenCalled(); + }); }); 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 933a085a1ff..5d622a0bdef 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 @@ -1,7 +1,14 @@ -import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { + Component, + OnDestroy, + OnInit, + TemplateRef, + ViewChild, + ViewEncapsulation +} from '@angular/core'; import { Router } from '@angular/router'; -import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs'; -import { catchError, map, switchMap } from 'rxjs/operators'; +import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs'; +import { catchError, finalize, map, shareReplay, switchMap, takeUntil, tap } from 'rxjs/operators'; import { GatewayGroup, NvmeofService } from '~/app/shared/api/nvmeof.service'; import { HostService } from '~/app/shared/api/host.service'; import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; @@ -25,6 +32,7 @@ import { NotificationService } from '~/app/shared/services/notification.service' import { NotificationType } from '~/app/shared/enum/notification-type.enum'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component'; +import { NvmeofStateService } from '../nvmeof-state.service'; const BASE_URL = 'block/nvmeof/gateways'; @@ -36,7 +44,9 @@ const BASE_URL = 'block/nvmeof/gateways'; encapsulation: ViewEncapsulation.None, providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }] }) -export class NvmeofGatewayGroupComponent implements OnInit { +export class NvmeofGatewayGroupComponent implements OnInit, OnDestroy { + private destroy$ = new Subject(); + @ViewChild(TableComponent, { static: true }) table: TableComponent; @@ -63,6 +73,7 @@ export class NvmeofGatewayGroupComponent implements OnInit { gatewayGroupName: string; subsystemCount: number; gatewayCount: number; + private lastGroupCount = 0; viewUrl = `/${BASE_URL}/view`; icons = Icons; @@ -79,7 +90,8 @@ export class NvmeofGatewayGroupComponent implements OnInit { public taskWrapper: TaskWrapperService, private notificationService: NotificationService, private urlBuilder: URLBuilderService, - private router: Router + private router: Router, + private nvmeofStateService: NvmeofStateService ) {} ngOnInit(): void { @@ -172,9 +184,20 @@ export class NvmeofGatewayGroupComponent implements OnInit { return of([]); }) ) - ) + ), + shareReplay({ bufferSize: 1, refCount: true }), + tap((groups) => { + const wasNonEmpty = this.lastGroupCount > 0; + this.lastGroupCount = groups.length; + if (wasNonEmpty && groups.length === 0) { + this.nvmeofStateService.requestRefresh(); + } + }) ); this.checkNodesAvailability(); + this.nvmeofStateService.refresh$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.fetchData()); } fetchData(): void { this.subject.next([]); @@ -240,17 +263,19 @@ export class NvmeofGatewayGroupComponent implements OnInit { call: this.cephServiceService.delete(serviceName) }) .pipe( - map(() => { - this.table.refreshBtn(); + tap({ + complete: () => { + this.nvmeofStateService.requestRefresh(); + } }), catchError((error) => { - this.table.refreshBtn(); this.notificationService.show( NotificationType.error, $localize`${`Failed to delete gateway group ${selectedGroup.spec.group}: ${error.message}`}` ); return of(null); - }) + }), + finalize(() => this.table?.refreshBtn()) ); } }); @@ -299,4 +324,9 @@ export class NvmeofGatewayGroupComponent implements OnInit { } this.router.navigate([this.viewUrl, groupName]); } + + ngOnDestroy(): void { + this.destroy$.next(); + this.destroy$.complete(); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts index 88d2d4f5fd5..3223e7a1c41 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts @@ -44,8 +44,6 @@ export class NvmeofGatewayComponent implements OnInit, OnDestroy { this.route.queryParams.subscribe((params) => { if (params['tab'] && Object.values(TABS).includes(params['tab'])) { this.activeTab = params['tab'] as TABS; - } else { - this.activeTab = TABS.gateways; } this.breadcrumbService.setTabCrumb(TAB_LABELS[this.activeTab]); }); 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 79c82c3c4a2..38c5c2fc78c 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,27 +1,6 @@ - - -
-
-
- - - -
-
-
+ + { let modalService: MockModalCdsService; beforeEach(async () => { + const nvmeofStateServiceMock = { + refresh$: new Subject(), + requestRefresh: jest.fn() + }; + await TestBed.configureTestingModule({ declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent], imports: [HttpClientModule, RouterTestingModule, SharedModule], @@ -70,9 +76,10 @@ describe('NvmeofNamespacesListComponent', () => { { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, { provide: ModalCdsService, useClass: MockModalCdsService }, - { provide: TaskWrapperService, useClass: MockTaskWrapperService } + { provide: TaskWrapperService, useClass: MockTaskWrapperService }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ], - schemas: [CUSTOM_ELEMENTS_SCHEMA] + schemas: [NO_ERRORS_SCHEMA] }).compileComponents(); fixture = TestBed.createComponent(NvmeofNamespacesListComponent); @@ -98,7 +105,7 @@ describe('NvmeofNamespacesListComponent', () => { ); done(); }); - component.listNamespaces(); + component.fetchData(); }); it('should open delete modal with correct data', () => { 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 37b1aa4d3a6..3217bc23cd8 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 @@ -1,6 +1,6 @@ import { Component, Input, NgZone, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; import { ActivatedRoute, Router } from '@angular/router'; -import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; @@ -11,16 +11,14 @@ import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof'; import { Permission } from '~/app/shared/models/permissions'; -import { CephServiceSpec } from '~/app/shared/models/service.interface'; import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; -import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; - -const DEFAULT_PLACEHOLDER = $localize`Enter group name`; +import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; @Component({ selector: 'cd-nvmeof-namespaces-list', @@ -42,11 +40,6 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { namespaces$: Observable; private namespaceSubject = new BehaviorSubject(undefined); - // Gateway group dropdown properties - gwGroups: GroupsComboboxItem[] = []; - gwGroupsEmpty: boolean = false; - gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER; - private destroy$ = new Subject(); constructor( @@ -58,16 +51,13 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { private authStorageService: AuthStorageService, private taskWrapper: TaskWrapperService, private nvmeofService: NvmeofService, - private dimlessBinaryPipe: DimlessBinaryPipe + private dimlessBinaryPipe: DimlessBinaryPipe, + private nvmeofStateService: NvmeofStateService ) { this.permission = this.authStorageService.getPermissions().nvmeof; } ngOnInit() { - this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { - if (params?.['group']) this.onGroupSelection({ content: params?.['group'] }); - }); - this.setGatewayGroups(); this.namespacesColumns = [ { name: $localize`Namespace ID`, @@ -166,78 +156,24 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { }), takeUntil(this.destroy$) ); + this.nvmeofStateService.refresh$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.fetchData()); } - updateSelection(selection: CdTableSelection) { - this.selection = selection; + onGroupChange(group: string | null): void { + this.group = group; + this.namespaceSubject.next(); } - listNamespaces() { - this.namespaceSubject.next(); + updateSelection(selection: CdTableSelection) { + this.selection = selection; } fetchData() { this.namespaceSubject.next(); } - // Gateway groups methods - onGroupSelection(selected: GroupsComboboxItem) { - selected.selected = true; - this.group = selected.content; - this.listNamespaces(); - } - - onGroupClear() { - this.group = null; - this.listNamespaces(); - } - - setGatewayGroups() { - this.nvmeofService - .listGatewayGroups() - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response), - error: (error) => this.handleGatewayGroupsError(error) - }); - } - - handleGatewayGroupsSuccess(response: CephServiceSpec[][]) { - if (response?.[0]?.length) { - this.gwGroups = this.nvmeofService.formatGwGroupsList(response); - } else { - this.gwGroups = []; - } - this.updateGroupSelectionState(); - } - - updateGroupSelectionState() { - if (this.gwGroups.length) { - if (!this.group) { - this.onGroupSelection(this.gwGroups[0]); - } else { - this.gwGroups = this.gwGroups.map((g) => ({ - ...g, - selected: g.content === this.group - })); - } - this.gwGroupsEmpty = false; - this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER; - } else { - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`No groups available`; - } - } - - handleGatewayGroupsError(error: any) { - this.gwGroups = []; - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`; - if (error?.preventDefault) { - error?.preventDefault?.(); - } - } - deleteNamespaceModal() { const namespace = this.selection.first(); const subsystemNqn = namespace.ns_subsystem_nqn; @@ -251,13 +187,15 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { deletionMessage: $localize`Deleting the namespace ${namespace.nsid} will permanently remove all resources, services, and configurations within it. This action cannot be undone.` }, submitActionObservable: () => - this.taskWrapper.wrapTaskAroundCall({ - task: new FinishedTask('nvmeof/namespace/delete', { - nqn: subsystemNqn, - nsid: namespace.nsid - }), - call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group) - }) + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/namespace/delete', { + nqn: subsystemNqn, + nsid: namespace.nsid + }), + call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.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 new file mode 100644 index 00000000000..d4fb4f1b491 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.html @@ -0,0 +1,57 @@ + + +
+

Recommended first-time setup

+

+ Start your NVMe over Fabrics configuration by creating the essential resources in sequence. +

+
+
+ +
+
+ + +
+ +
+ + +
+ +
+ + +
+
+ + @if (isAllConfigured) { + + } +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.scss new file mode 100644 index 00000000000..e69de29bb2d 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 new file mode 100644 index 00000000000..a41e9e24f0e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.spec.ts @@ -0,0 +1,160 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterModule } from '@angular/router'; + +import { NvmeofSetupCardsComponent } from './nvmeof-setup-cards.component'; + +describe('NvmeofSetupCardsComponent', () => { + let component: NvmeofSetupCardsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [NvmeofSetupCardsComponent, RouterModule.forRoot([])] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofSetupCardsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render 3 setup step cards', () => { + const cards = fixture.nativeElement.querySelectorAll('cd-setup-step-card'); + expect(cards.length).toBe(3); + }); + + it('should not show completion link when isAllConfigured is false', () => { + component.isAllConfigured = false; + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion'); + expect(link).toBeNull(); + }); + + it('should show completion link when isAllConfigured is true', () => { + component.isAllConfigured = true; + fixture.detectChanges(); + const link = fixture.nativeElement.querySelector('.nvmeof-setup-cards__completion'); + expect(link).toBeTruthy(); + }); + + describe('setup state', () => { + const getStepCards = () => + fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + it('should show gateway-only setup state when gateway exists but subsystems and namespaces do not', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cards = getStepCards(); + expect(cards[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cards[1].componentInstance.statusMessage).toBe( + 'No subsystem configured for this cluster yet.' + ); + expect(cards[2].componentInstance.statusMessage).toBe( + 'No namespace allocated or mapped yet.' + ); + }); + + it('should show subsystem-complete setup state when gateway and subsystem exist', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = true; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cards = getStepCards(); + expect(cards[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cards[1].componentInstance.statusMessage).toBe('Subsystem configured successfully.'); + expect(cards[2].componentInstance.statusMessage).toBe( + 'No namespace allocated or mapped yet.' + ); + }); + }); + + it('should display "No gateway configured yet." on step 2 and 3 when hasGatewayGroups is false', () => { + component.hasGatewayGroups = false; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + 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.'); + }); + + it('should display original info messages when hasGatewayGroups is true', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + const subsystemCard = cardElements[1].componentInstance; + const namespaceCard = cardElements[2].componentInstance; + + expect(subsystemCard.statusMessage).toBe('No subsystem configured for this cluster yet.'); + expect(namespaceCard.statusMessage).toBe('No namespace allocated or mapped yet.'); + }); + + it('should display gateway info message when hasGatewayGroups is false', () => { + component.hasGatewayGroups = false; + fixture.detectChanges(); + + const gatewayCard = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card')[0] + .componentInstance; + + expect(gatewayCard.statusMessage).toBe('No gateway groups configured for this cluster yet.'); + }); + + it('should display success messages when all setup steps are configured', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = true; + component.hasNamespaces = true; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(cardElements[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cardElements[1].componentInstance.statusMessage).toBe( + 'Subsystem configured successfully.' + ); + expect(cardElements[2].componentInstance.statusMessage).toBe('Namespaces mapped successfully.'); + }); + + it('should update all step messages when gateway groups are removed after full configuration', () => { + component.hasGatewayGroups = true; + component.hasSubsystems = true; + component.hasNamespaces = true; + fixture.detectChanges(); + + const getCards = () => fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(getCards()[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + + component.hasGatewayGroups = false; + component.hasSubsystems = false; + component.hasNamespaces = false; + fixture.detectChanges(); + + 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.'); + }); +}); 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 new file mode 100644 index 00000000000..9dc9100fa90 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-setup-cards/nvmeof-setup-cards.component.ts @@ -0,0 +1,53 @@ +import { CommonModule } from '@angular/common'; +import { Component, Input, 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'; +import { SetupStepCardComponent } from '~/app/shared/components/setup-step-card/setup-step-card.component'; + +@Component({ + selector: 'cd-nvmeof-setup-cards', + templateUrl: './nvmeof-setup-cards.component.html', + styleUrls: ['./nvmeof-setup-cards.component.scss'], + standalone: true, + encapsulation: ViewEncapsulation.None, + imports: [ + CommonModule, + RouterModule, + LayoutModule, + LayerModule, + TilesModule, + LinkModule, + ProductiveCardComponent, + SetupStepCardComponent + ] +}) +export class NvmeofSetupCardsComponent { + @Input() hasGatewayGroups = false; + @Input() hasSubsystems = false; + @Input() hasNamespaces = false; + @Input() isAllConfigured = false; + + readonly gatewayPendingMessage = $localize`No gateway configured yet.`; + + readonly cards = { + gateway: { + title: $localize`Create Gateway groups`, + description: $localize`Group NVMe gateway nodes to enable high availability and load balancing for storage targets.`, + successMessage: $localize`Gateway group configured successfully.`, + infoMessage: $localize`No gateway groups configured for this cluster yet.` + }, + subsystem: { + title: $localize`Create Subsystems`, + description: $localize`Define storage targets by creating NVMe subsystems and configuring security, listeners, and host access.`, + successMessage: $localize`Subsystem configured successfully.`, + infoMessage: $localize`No subsystem configured for this cluster yet.` + }, + namespace: { + title: $localize`Create Namespaces`, + description: $localize`Create storage namespaces backed by Ceph block images. This completes your NVMe over Fabrics setup.`, + successMessage: $localize`Namespaces mapped successfully.`, + infoMessage: $localize`No namespace allocated or mapped yet.` + } + }; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts new file mode 100644 index 00000000000..ccdd04dcd25 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.spec.ts @@ -0,0 +1,187 @@ +import { TestBed } from '@angular/core/testing'; + +import { Subject } from 'rxjs'; + +import { NvmeofStateService } from './nvmeof-state.service'; +import { SummaryService } from '~/app/shared/services/summary.service'; + +describe('NvmeofStateService', () => { + let service: NvmeofStateService; + let summaryData$: Subject; + let summaryServiceSpy: any; + + const emitSummary = (tasks: any[]) => summaryData$.next({ finished_tasks: tasks }); + + beforeEach(() => { + summaryData$ = new Subject(); + summaryServiceSpy = { + subscribe: jest.fn().mockImplementation((callback: (s: any) => void) => { + return summaryData$.subscribe(callback); + }) + }; + + TestBed.configureTestingModule({ + providers: [NvmeofStateService, { provide: SummaryService, useValue: summaryServiceSpy }] + }); + + service = TestBed.inject(NvmeofStateService); + }); + + it('should create', () => { + expect(service).toBeTruthy(); + }); + + it('should emit refresh$ when requestRefresh is called', (done) => { + service.refresh$.subscribe(() => done()); + service.requestRefresh(); + }); + + it('should not emit refresh$ on the first summary update (initialization baseline)', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([ + { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + } + ]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('should emit refresh$ when a new NVMe service/delete task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a new NVMe service/create task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'service/create', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a nvmeof/subsystem/delete task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'nvmeof/subsystem/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { nqn: 'sub1' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a nvmeof/namespace/create task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'nvmeof/namespace/create', + begin_time: '2026-01-01T00:00:00Z', + metadata: { nqn: 'sub1' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should emit refresh$ when a nvmeof/namespace/delete task appears', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'nvmeof/namespace/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { nsid: 1, nqn: 'sub1' } + } + ]); + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should not emit refresh$ for non-NVMe tasks', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([{ name: 'rbd/create', begin_time: '2026-01-01T00:00:00Z', metadata: {} }]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('should not emit refresh$ for service/delete of a non-NVMe service', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + emitSummary([]); // init + emitSummary([ + { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'rbd.default' } + } + ]); + + expect(refreshSpy).not.toHaveBeenCalled(); + }); + + it('should not emit refresh$ for the same task appearing again', () => { + const refreshSpy = jest.fn(); + service.refresh$.subscribe(refreshSpy); + + const task = { + name: 'service/delete', + begin_time: '2026-01-01T00:00:00Z', + metadata: { service_name: 'nvmeof.rbd.default' } + }; + + emitSummary([]); // init + emitSummary([task]); // new task — emits once + emitSummary([task]); // same task — no second emit + + expect(refreshSpy).toHaveBeenCalledTimes(1); + }); + + it('should unsubscribe from SummaryService on destroy', () => { + const unsubscribeSpy = jest.fn(); + const mockSubscription = { unsubscribe: unsubscribeSpy }; + summaryServiceSpy.subscribe.mockReturnValueOnce(mockSubscription); + + const freshService = new NvmeofStateService(summaryServiceSpy as any); + freshService.ngOnDestroy(); + + expect(unsubscribeSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts new file mode 100644 index 00000000000..c8343a29f9b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-state.service.ts @@ -0,0 +1,95 @@ +import { Injectable, OnDestroy } from '@angular/core'; + +import { merge, Subject, Subscription } from 'rxjs'; + +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { Summary } from '~/app/shared/models/summary.model'; +import { SummaryService } from '~/app/shared/services/summary.service'; + +/** + * Provides a unified refresh$ stream for NVMe-oF UI components to react to + * task completions and explicit refresh requests. + * + * A dedicated service is used instead of reusing TaskListService because + * TaskListService is tightly coupled to the task-list UI component and does + * not expose a clean observable suitable for driving side-effects such as + * setup-card state reloads. + * + * This service is provided at the NvmeofTabsComponent level to ensure + * subscriptions are properly destroyed when navigating away from nvmeof pages. + */ +@Injectable() +export class NvmeofStateService implements OnDestroy { + private readonly explicitRefreshSource = new Subject(); + private readonly taskRefreshSource = new Subject(); + private summarySubscription: Subscription; + private seenTaskSignatures = new Set(); + private summaryInitialized = false; + + readonly refresh$ = merge( + this.explicitRefreshSource.asObservable(), + this.taskRefreshSource.asObservable() + ); + + constructor(private summaryService: SummaryService) { + this.summarySubscription = this.summaryService.subscribe((summary: Summary) => { + const nvmeTasks = (summary?.finished_tasks ?? []).filter((task: FinishedTask) => + this.isNvmeTask(task) + ); + const currentSignatures = new Set( + nvmeTasks.map((task: FinishedTask) => this.getTaskSignature(task)) + ); + + if (!this.summaryInitialized) { + this.summaryInitialized = true; + this.seenTaskSignatures = currentSignatures; + return; + } + + const hasNewTask = [...currentSignatures].some( + (taskSignature) => !this.seenTaskSignatures.has(taskSignature) + ); + this.seenTaskSignatures = currentSignatures; + + if (hasNewTask) { + this.taskRefreshSource.next(); + } + }); + } + + ngOnDestroy(): void { + this.summarySubscription?.unsubscribe(); + } + + requestRefresh(): void { + this.explicitRefreshSource.next(); + } + + private isNvmeTask(task: FinishedTask): boolean { + if (!task?.name) { + return false; + } + + if (task.name === 'service/create' || task.name === 'service/delete') { + const serviceName = task.metadata?.['service_name']; + return typeof serviceName === 'string' && serviceName.startsWith('nvmeof.'); + } + + return [ + 'nvmeof/gateway/create', + 'nvmeof/gateway/delete', + 'nvmeof/subsystem/create', + 'nvmeof/subsystem/delete', + 'nvmeof/namespace/create', + 'nvmeof/namespace/delete' + ].includes(task.name); + } + + private getTaskSignature(task: FinishedTask): string { + return JSON.stringify({ + name: task.name, + begin_time: task.begin_time, + metadata: task.metadata + }); + } +} 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 68927708547..57fa67a8b89 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,10 +2,11 @@ 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 } from 'rxjs'; +import { of, Subject } from 'rxjs'; import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list.component'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { SharedModule } from '~/app/shared/shared.module'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; @@ -42,6 +43,11 @@ describe('NvmeofSubsystemNamespacesListComponent', () => { } beforeEach(async () => { + const nvmeofStateServiceMock = { + refresh$: new Subject(), + requestRefresh: jest.fn() + }; + await TestBed.configureTestingModule({ declarations: [NvmeofSubsystemNamespacesListComponent], imports: [HttpClientTestingModule, RouterTestingModule, SharedModule], @@ -61,7 +67,8 @@ describe('NvmeofSubsystemNamespacesListComponent', () => { listNamespaces: jest.fn().mockReturnValue(of(mockNamespaces)) } }, - { provide: AuthStorageService, useClass: MockAuthStorageService } + { provide: AuthStorageService, useClass: MockAuthStorageService }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ] }).compileComponents(); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts index 852f0a006d8..fe8e6591d66 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component.ts @@ -14,8 +14,9 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { combineLatest, Subject } from 'rxjs'; -import { takeUntil } from 'rxjs/operators'; +import { catchError, takeUntil, tap } from 'rxjs/operators'; @Component({ selector: 'cd-nvmeof-subsystem-namespaces-list', @@ -35,7 +36,6 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy private destroy$ = new Subject(); constructor( - // ... constructor stays mostly same public actionLabels: ActionLabelsI18n, private router: Router, private modalService: ModalCdsService, @@ -44,7 +44,8 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy private nvmeofService: NvmeofService, private dimlessBinaryPipe: DimlessBinaryPipe, private iopsPipe: IopsPipe, - private route: ActivatedRoute + private route: ActivatedRoute, + private nvmeofStateService: NvmeofStateService ) { this.permission = this.authStorageService.getPermissions().nvmeof; } @@ -155,7 +156,13 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy if (this.group) { this.nvmeofService .listNamespaces(this.group, this.subsystemNQN) - .pipe(takeUntil(this.destroy$)) + .pipe( + catchError(() => { + this.namespaces = []; + return []; + }), + takeUntil(this.destroy$) + ) .subscribe((res: NvmeofSubsystemNamespace[]) => { this.namespaces = res || []; }); @@ -171,13 +178,15 @@ export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy itemNames: [namespace.nsid], actionDescription: 'delete', submitActionObservable: () => - this.taskWrapper.wrapTaskAroundCall({ - task: new FinishedTask('nvmeof/namespace/delete', { - nqn: this.subsystemNQN, - nsid: namespace.nsid - }), - call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group) - }) + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/namespace/delete', { + nqn: this.subsystemNQN, + nsid: namespace.nsid + }), + call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group) + }) + .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() })) }); } 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 3111ae2e8d4..aef098c0d86 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,27 +1,7 @@ - + + -
-
-
- - - -
-
-
({ + content: g.spec.group, + selected: false + })); } -]; -class MockNvmeOfService { listSubsystems() { return of(mockSubsystems); } @@ -66,14 +58,6 @@ class MockNvmeOfService { getInitiators() { return of([]); } - - formatGwGroupsList(_data: CephServiceSpec[][]) { - return mockformattedGwGroups; - } - - listGatewayGroups() { - return of(mockGroups); - } } class MockAuthStorageService { @@ -89,21 +73,55 @@ class MockTaskWrapperService {} describe('NvmeofSubsystemsComponent', () => { let component: NvmeofSubsystemsComponent; let fixture: ComponentFixture; + let nvmeofService: MockNvmeOfService; + let queryParams$: BehaviorSubject>; + const activatedRouteMock = { + queryParams: null as any, + snapshot: { queryParams: {} as Record } + }; beforeEach(async () => { + 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], + imports: [ + HttpClientModule, + RouterTestingModule, + SharedModule, + ComboBoxModule, + GridModule, + NvmeofGatewayGroupFilterComponent + ], providers: [ { provide: NvmeofService, useClass: MockNvmeOfService }, { provide: AuthStorageService, useClass: MockAuthStorageService }, { provide: ModalCdsService, useClass: MockModalService }, - { provide: TaskWrapperService, useClass: MockTaskWrapperService } + { provide: TaskWrapperService, useClass: MockTaskWrapperService }, + { provide: ActivatedRoute, useValue: activatedRouteMock }, + { provide: NvmeofStateService, useValue: nvmeofStateServiceMock } ] }).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(NvmeofSubsystemsComponent); component = fixture.componentInstance; + nvmeofService = TestBed.inject(NvmeofService) as any; component.ngOnInit(); fixture.detectChanges(); }); @@ -115,22 +133,31 @@ describe('NvmeofSubsystemsComponent', () => { it('should retrieve subsystems', (done) => { const expected = mockSubsystems.map((s) => ({ ...s, - gw_group: component.group, + gw_group: 'default', auth: 'No authentication', initiator_count: 0 })); - component.subsystems$.subscribe((subsystems) => { + component.onGroupChange('default'); + component.subsystems$.pipe(skip(1), take(1)).subscribe((subsystems) => { expect(subsystems).toEqual(expected); done(); }); - component.getSubsystems(); + component.fetchData(); }); - it('should load gateway groups correctly', () => { - expect(component.gwGroups.length).toBe(2); + it('should not fetch subsystems when group is not selected', (done) => { + const listSubsystemsSpy = jest.spyOn(nvmeofService, 'listSubsystems'); + component.group = null; + component.fetchData(); + + component.subsystems$.pipe(take(1)).subscribe((subsystems) => { + expect(subsystems).toEqual([]); + expect(listSubsystemsSpy).not.toHaveBeenCalled(); + done(); + }); }); it('should set first group as default initially', () => { - expect(component.group).toBe(mockGroups[0][0].spec.group); + expect(component.group).toBe('default'); }); }); 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 85590b314b5..cc2467f0652 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 @@ -1,5 +1,5 @@ import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core'; -import { ActivatedRoute, Router } from '@angular/router'; +import { Router } from '@angular/router'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { @@ -18,15 +18,14 @@ import { Icons } from '~/app/shared/enum/icons.enum'; import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; import { FinishedTask } from '~/app/shared/models/finished-task'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; -import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; -import { CephServiceSpec } from '~/app/shared/models/service.interface'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs'; import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators'; import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum'; const BASE_URL = 'block/nvmeof/subsystems'; -const DEFAULT_PLACEHOLDER = $localize`Enter group name`; @Component({ selector: 'cd-nvmeof-subsystems', @@ -55,10 +54,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit tableActions: CdTableAction[]; subsystemDetails: any[]; context: CdTableFetchDataContext; - gwGroups: GroupsComboboxItem[] = []; group: string = null; - gwGroupsEmpty: boolean = false; - gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER; authType = NvmeofSubsystemAuthType; subsystems$: Observable<(NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[]>; private subsystemSubject = new BehaviorSubject(undefined); @@ -70,19 +66,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit private authStorageService: AuthStorageService, public actionLabels: ActionLabelsI18n, private router: Router, - private route: ActivatedRoute, private modalService: ModalCdsService, - private taskWrapper: TaskWrapperService + private taskWrapper: TaskWrapperService, + private nvmeofStateService: NvmeofStateService ) { super(); this.permissions = this.authStorageService.getPermissions(); } ngOnInit() { - this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => { - if (params?.['group']) this.onGroupSelection({ content: params?.['group'] }); - }); - this.setGatewayGroups(); this.subsystemsColumns = [ { name: $localize`Subsystem NQN`, @@ -152,14 +144,18 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit }), takeUntil(this.destroy$) ); + this.nvmeofStateService.refresh$ + .pipe(takeUntil(this.destroy$)) + .subscribe(() => this.fetchData()); } - updateSelection(selection: CdTableSelection) { - this.selection = selection; + onGroupChange(group: string | null): void { + this.group = group; + this.subsystemSubject.next(); } - getSubsystems() { - this.subsystemSubject.next(); + updateSelection(selection: CdTableSelection) { + this.selection = selection; } fetchData() { @@ -179,68 +175,15 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit forceDeleteAcknowledgementMessage: $localize`I understand this may remove resources still attached to this subsystem.` }, submitActionObservable: () => - this.taskWrapper.wrapTaskAroundCall({ - task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }), - call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group) - }) + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }), + call: this.nvmeofService.deleteSubsystem(subsystem.nqn, this.group) + }) + .pipe(tap({ complete: () => this.nvmeofStateService.requestRefresh() })) }); } - onGroupSelection(selected: GroupsComboboxItem) { - selected.selected = true; - this.group = selected.content; - this.getSubsystems(); - } - - onGroupClear() { - this.group = null; - this.getSubsystems(); - } - - setGatewayGroups() { - this.nvmeofService - .listGatewayGroups() - .pipe(takeUntil(this.destroy$)) - .subscribe({ - next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response), - error: (error) => this.handleGatewayGroupsError(error) - }); - } - - handleGatewayGroupsSuccess(response: CephServiceSpec[][]) { - if (response?.[0]?.length) { - this.gwGroups = this.nvmeofService.formatGwGroupsList(response); - } else { - this.gwGroups = []; - } - this.updateGroupSelectionState(); - } - - updateGroupSelectionState() { - if (this.gwGroups.length) { - this.gwGroupsEmpty = false; - this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER; - if (!this.group) { - this.onGroupSelection(this.gwGroups[0]); - } else { - this.gwGroups = this.gwGroups.map((g) => ({ - ...g, - selected: g.content === this.group - })); - } - } else { - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`No groups available`; - } - } - - handleGatewayGroupsError(error: any) { - this.gwGroups = []; - this.gwGroupsEmpty = true; - this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`; - this.handleError(error); - } - private handleError(error: any): void { if (error?.preventDefault) { error?.preventDefault?.(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html index 9b3388838d5..a4f9f1e5241 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html @@ -1,31 +1,45 @@ -
- -

NVMe over Fabrics (TCP)

- Monitor and manage NVMe-over-TCP resources for high-performance block storage. -
-
-
- - - - - - - - -
+@if (showTabsShell) { +
+ +

NVMe over Fabrics (TCP)

+

Monitor and manage NVMe-over-TCP resources for high-performance block storage.

+
+
+ + @if (showSetupCards) { + + + } + +
+ + + + + + + + +
+} + + 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 cdd1ea2e9cf..b97da033c23 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,26 +1,79 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { Router } from '@angular/router'; +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'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; import { NvmeofTabsComponent } from './nvmeof-tabs.component'; import { SharedModule } from '~/app/shared/shared.module'; +import { NvmeofSetupCardsComponent } from '../nvmeof-setup-cards/nvmeof-setup-cards.component'; + +type SetupState = { + hasGatewayGroups: boolean; + hasSubsystems: boolean; + hasNamespaces: boolean; +}; describe('NvmeofTabsComponent', () => { let component: NvmeofTabsComponent; let fixture: ComponentFixture; let router: Router; + let nvmeofServiceSpy: any; + let queryParams$: BehaviorSubject; + let refresh$: Subject; + let routerEvents$: Subject; + let currentSetupState: SetupState; + + const setQueryParams = (params: any) => queryParams$.next(params); + const emitRefresh = () => refresh$.next(); + const setSetupState = (state: SetupState) => { + currentSetupState = state; + nvmeofServiceSpy.fetchSetupState.mockReturnValue(of(currentSetupState)); + }; beforeEach(async () => { - await TestBed.configureTestingModule({ + queryParams$ = new BehaviorSubject({ group: 'grp1' }); + refresh$ = new Subject(); + const nvmeofStateServiceMock = { + refresh$: refresh$.asObservable() + }; + currentSetupState = { hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }; + nvmeofServiceSpy = { + fetchSetupState: jest.fn().mockImplementation(() => of(currentSetupState)) + }; + + TestBed.configureTestingModule({ declarations: [NvmeofTabsComponent], - imports: [RouterTestingModule, SharedModule, TabsModule] - }).compileComponents(); + imports: [ + RouterTestingModule, + HttpClientTestingModule, + SharedModule, + TabsModule, + NvmeofSetupCardsComponent + ], + providers: [ + { provide: NvmeofService, useValue: nvmeofServiceSpy }, + { provide: ActivatedRoute, useValue: { queryParams: queryParams$.asObservable() } } + ] + }); + TestBed.overrideComponent(NvmeofTabsComponent, { + set: { providers: [{ provide: NvmeofStateService, useValue: nvmeofStateServiceMock }] } + }); + await TestBed.compileComponents(); fixture = TestBed.createComponent(NvmeofTabsComponent); component = fixture.componentInstance; router = TestBed.inject(Router); + routerEvents$ = new Subject(); + Object.defineProperty(router, 'url', { + get: () => '/block/nvmeof/gateways', + configurable: true + }); + jest.spyOn(router, 'events', 'get').mockReturnValue(routerEvents$.asObservable()); }); it('should create', () => { @@ -51,25 +104,59 @@ describe('NvmeofTabsComponent', () => { 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'); + 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'); + 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'); + 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'); + 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.selectedTab).toBe(component.Tabs.subsystems); - expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/subsystems']); + expect(component.activeTab).toBe(component.Tabs.subsystems); + expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'subsystems'], { + queryParamsHandling: 'preserve' + }); }); it('should navigate to gateways on selecting gateways tab', () => { spyOn(router, 'navigate'); component.onSelected(component.Tabs.gateways); - expect(component.selectedTab).toBe(component.Tabs.gateways); - expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/gateways']); + expect(component.activeTab).toBe(component.Tabs.gateways); + expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'gateways'], { + queryParamsHandling: 'preserve' + }); }); it('should navigate to namespaces on selecting namespaces tab', () => { spyOn(router, 'navigate'); component.onSelected(component.Tabs.namespaces); - expect(component.selectedTab).toBe(component.Tabs.namespaces); - expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof/namespaces']); + expect(component.activeTab).toBe(component.Tabs.namespaces); + expect(router.navigate).toHaveBeenCalledWith(['block/nvmeof', 'namespaces'], { + queryParamsHandling: 'preserve' + }); }); it('should expose TABS enum via Tabs getter', () => { @@ -78,4 +165,229 @@ describe('NvmeofTabsComponent', () => { expect(tabs.subsystems).toBe('subsystems'); expect(tabs.namespaces).toBe('namespaces'); }); + + describe('setup cards scenarios', () => { + it('should show setup cards', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + expect(component.showSetupCards).toBe(true); + }); + + it('should detect subsystems and namespaces regardless of dropdown selection', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }); + component.ngOnInit(); + setQueryParams({}); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(true); + expect(component.isAllConfigured).toBe(true); + }); + + it('scenario: no gateway groups — all steps pending', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + expect(component.showSetupCards).toBe(true); + }); + + it('scenario: gateway groups exist, no subsystems across all groups — step 1 complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: no subsystems in object response across all groups — step 1 complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: subsystems in any group, no namespaces — steps 1 & 2 complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: all configured across any group — isAllConfigured is true', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(true); + expect(component.isAllConfigured).toBe(true); + }); + + it('scenario: all configured in object response across any group — isAllConfigured is true', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: true, hasNamespaces: true }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(true); + expect(component.hasNamespaces).toBe(true); + expect(component.isAllConfigured).toBe(true); + }); + + it('scenario: subsystems exist in grp2 only — step 2 still marked complete', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('scenario: full config in grp2 only — onboarding complete regardless of selected group', () => { + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + setQueryParams({ group: 'grp1' }); + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should trigger state reload when refresh$ emits', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + }); + + it('should reload state on NavigationEnd (e.g. navigating back from a form)', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + + routerEvents$.next( + new NavigationEnd(1, '/block/nvmeof/namespaces', '/block/nvmeof/namespaces') + ); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + }); + + it('should show the initial gateway-only state after the last subsystem and namespace are removed', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should show the initial empty state after the last gateway is removed', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should refresh setup cards when the gateway list refreshes to empty', () => { + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(false); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should show gateway step complete after a gateway is created', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: true, hasSubsystems: false, hasNamespaces: false }); + + emitRefresh(); + + expect(component.hasGatewayGroups).toBe(true); + expect(component.hasSubsystems).toBe(false); + expect(component.hasNamespaces).toBe(false); + expect(component.isAllConfigured).toBe(false); + }); + + it('should use fetchSetupState on refresh', () => { + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + component.ngOnInit(); + nvmeofServiceSpy.fetchSetupState.mockClear(); + + emitRefresh(); + + expect(nvmeofServiceSpy.fetchSetupState).toHaveBeenCalledTimes(1); + }); + + 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 }); + component.ngOnInit(); + + setSetupState({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }); + emitRefresh(); + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(cardElements.length).toBe(3); + 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.'); + }); + + it('should render success setup card messages before gateway groups are removed', () => { + jest.spyOn(router, 'url', 'get').mockReturnValue('/block/nvmeof/gateways'); + component.ngOnInit(); + emitRefresh(); + fixture.detectChanges(); + component.showSetupCards = true; + fixture.detectChanges(); + + const cardElements = fixture.debugElement.queryAll((el) => el.name === 'cd-setup-step-card'); + + expect(cardElements[0].componentInstance.statusMessage).toBe( + 'Gateway group configured successfully.' + ); + expect(cardElements[1].componentInstance.statusMessage).toBe( + 'Subsystem configured successfully.' + ); + expect(cardElements[2].componentInstance.statusMessage).toBe( + 'Namespaces mapped successfully.' + ); + }); + }); }); 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 8b74346db92..a42cd572a2c 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 @@ -1,5 +1,10 @@ -import { Component, OnInit } from '@angular/core'; -import { Router } from '@angular/router'; +import { Component, OnDestroy, OnInit } from '@angular/core'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { Subscription, merge, of } from 'rxjs'; +import { filter, switchMap, tap } from 'rxjs/operators'; + +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { NvmeofStateService } from '../nvmeof-state.service'; const NVMEOF_PATH = 'block/nvmeof'; @@ -9,26 +14,85 @@ enum TABS { namespaces = 'namespaces' } +const TAB_ROUTES = [ + '/block/nvmeof/gateways', + '/block/nvmeof/subsystems', + '/block/nvmeof/namespaces' +]; + @Component({ selector: 'cd-nvmeof-tabs', templateUrl: './nvmeof-tabs.component.html', styleUrls: ['./nvmeof-tabs.component.scss'], - standalone: false + standalone: false, + providers: [NvmeofStateService] }) -export class NvmeofTabsComponent implements OnInit { - selectedTab: TABS; +export class NvmeofTabsComponent implements OnInit, OnDestroy { activeTab: TABS = TABS.gateways; + showTabsShell = true; + showSetupCards = false; + hasGatewayGroups = false; + hasSubsystems = false; + hasNamespaces = false; + isAllConfigured = false; + private setupSubscription?: Subscription; - constructor(private router: Router) {} + constructor( + private router: Router, + private route: ActivatedRoute, + private nvmeofService: NvmeofService, + private nvmeofStateService: NvmeofStateService + ) {} - ngOnInit(): void { - const currentPath = this.router.url; + private updateActiveTab(currentPath: string): void { this.activeTab = Object.values(TABS).find((tab) => currentPath.includes(tab)) || TABS.gateways; } + private updateShellVisibility(currentPath: string): void { + const urlTree = this.router.parseUrl(currentPath); + const primarySegments = + urlTree.root.children['primary']?.segments.map((segment) => segment.path) ?? []; + const primaryPath = `/${primarySegments.join('/')}`; + + this.showTabsShell = TAB_ROUTES.includes(primaryPath); + } + + ngOnInit(): void { + this.updateActiveTab(this.router.url); + this.updateShellVisibility(this.router.url); + + // Merge all trigger streams to prevent memory leaks and race conditions + this.setupSubscription = merge( + of(null), // Initial load + this.router.events.pipe( + filter((event): event is NavigationEnd => event instanceof NavigationEnd), + tap((event) => { + this.updateActiveTab(event.urlAfterRedirects); + this.updateShellVisibility(event.urlAfterRedirects); + }) + ), + this.route.queryParams, + this.nvmeofStateService.refresh$ + ) + .pipe(switchMap(() => this.nvmeofService.fetchSetupState())) + .subscribe(({ hasGatewayGroups, hasSubsystems, hasNamespaces }) => { + this.hasGatewayGroups = hasGatewayGroups; + this.hasSubsystems = hasSubsystems; + this.hasNamespaces = hasNamespaces; + this.isAllConfigured = hasGatewayGroups && hasSubsystems && hasNamespaces; + this.showSetupCards = !this.isAllConfigured; + }); + } + + ngOnDestroy(): void { + this.setupSubscription?.unsubscribe(); + } + onSelected(tab: TABS) { - this.selectedTab = tab; - this.router.navigate([`${NVMEOF_PATH}/${tab}`]); + this.activeTab = tab; + this.router.navigate([NVMEOF_PATH, tab], { + queryParamsHandling: 'preserve' + }); } public get Tabs(): typeof TABS { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts index 77c31a6553d..121299a9d30 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts @@ -3,15 +3,21 @@ import { HttpClient } from '@angular/common/http'; import _ from 'lodash'; import { Observable, forkJoin, of as observableOf } from 'rxjs'; -import { catchError, map, mapTo, mergeMap } from 'rxjs/operators'; +import { catchError, map, mapTo, mergeMap, switchMap } from 'rxjs/operators'; import { CephServiceSpec } from '../models/service.interface'; -import { ListenerItem } from '../models/nvmeof'; +import { ListenerItem, NvmeofSubsystem, NvmeofSubsystemNamespace } from '../models/nvmeof'; import { HostService } from './host.service'; import { OrchestratorService } from './orchestrator.service'; import { HostStatus } from '../enum/host-status.enum'; import { Host } from '../models/host.interface'; import { OrchestratorStatus } from '../models/orchestrator.interface'; +export type SetupState = { + hasGatewayGroups: boolean; + hasSubsystems: boolean; + hasNamespaces: boolean; +}; + export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512; export type GatewayGroup = CephServiceSpec; @@ -173,6 +179,60 @@ export class NvmeofService { }, []); } + private normalizeListResponse(response: unknown, key: string): T[] { + if (Array.isArray(response)) { + return response as T[]; + } + + const nested = (response as Record | null)?.[key]; + return Array.isArray(nested) ? nested : []; + } + + fetchSetupState(): Observable { + return this.listGatewayGroups().pipe( + switchMap((gatewayGroups: CephServiceSpec[][]) => { + const rawGroups = Array.isArray(gatewayGroups[0]) ? gatewayGroups[0] : []; + const groups = rawGroups.filter((serviceSpec: CephServiceSpec) => serviceSpec?.spec?.group); + const hasGatewayGroups = groups.length > 0; + + if (!hasGatewayGroups) { + return observableOf({ + hasGatewayGroups: false, + hasSubsystems: false, + hasNamespaces: false + }); + } + + const firstGroupName = groups[0].spec.group; + + return forkJoin({ + subsystems: this.listSubsystems(firstGroupName).pipe( + map((resp: unknown) => this.normalizeListResponse(resp, 'subsystems')), + catchError(() => observableOf([])) + ), + namespaces: this.listNamespaces(firstGroupName).pipe( + map((resp: unknown) => + this.normalizeListResponse(resp, 'namespaces') + ), + catchError(() => observableOf([])) + ) + }).pipe( + map(({ subsystems, namespaces }) => ({ + hasGatewayGroups, + hasSubsystems: subsystems.length > 0, + hasNamespaces: namespaces.length > 0 + })), + catchError(() => + observableOf({ hasGatewayGroups, hasSubsystems: false, hasNamespaces: false }) + ) + ); + }), + catchError(() => + observableOf({ hasGatewayGroups: false, hasSubsystems: false, hasNamespaces: false }) + ) + ); + } + // Gateway groups listGatewayGroups() { return this.http.get(`${API_PATH}/gateway/group`); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html new file mode 100644 index 00000000000..d2cd4c23f30 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.html @@ -0,0 +1,20 @@ +
+
+ {{ stepNumber }}. + {{ title }} +
+

+ {{ description }} +

+
+ @if (isLoading) { + + } @else if (isConfigured) { + + {{ statusMessage }} + } @else { + + {{ statusMessage }} + } +
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss new file mode 100644 index 00000000000..5becfaf4f90 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.scss @@ -0,0 +1,24 @@ +.setup-step-card { + display: flex; + flex-direction: column; + gap: var(--cds-spacing-03); + height: 100%; + + &--loading { + opacity: 0.7; + pointer-events: none; + } + + &__step-row { + display: flex; + align-items: baseline; + gap: var(--cds-spacing-03); + } + + &__info { + display: flex; + align-items: center; + margin-top: auto; + gap: var(--cds-spacing-03); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts new file mode 100644 index 00000000000..0414795e78f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.spec.ts @@ -0,0 +1,134 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SetupStepCardComponent } from './setup-step-card.component'; + +describe('SetupStepCardComponent', () => { + let component: SetupStepCardComponent; + let fixture: ComponentFixture; + + const STEP_TITLE = 'Example step'; + const STEP_DESCRIPTION = 'Example step description.'; + const SUCCESS_MESSAGE = 'Example configured successfully.'; + const INFO_MESSAGE = 'Example not configured yet.'; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [SetupStepCardComponent] + }).compileComponents(); + + fixture = TestBed.createComponent(SetupStepCardComponent); + component = fixture.componentInstance; + component.stepNumber = 1; + component.title = STEP_TITLE; + component.description = STEP_DESCRIPTION; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should render step number, title, and description', () => { + const element = fixture.nativeElement; + expect(element.textContent).toContain('1.'); + expect(element.textContent).toContain(STEP_TITLE); + expect(element.textContent).toContain(STEP_DESCRIPTION); + }); + + describe('statusMessage', () => { + it('should return successMessage when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + + expect(component.statusMessage).toBe(SUCCESS_MESSAGE); + }); + + it('should return infoMessage when not configured', () => { + component.isConfigured = false; + component.infoMessage = INFO_MESSAGE; + + expect(component.statusMessage).toBe(INFO_MESSAGE); + }); + + it('should return default success text when configured without successMessage', () => { + component.isConfigured = true; + component.successMessage = undefined; + + expect(component.statusMessage).toBe('Configured successfully.'); + }); + + it('should return default info text when not configured without infoMessage', () => { + component.isConfigured = false; + component.infoMessage = undefined; + + expect(component.statusMessage).toBe('Not configured yet.'); + }); + + it('should prefer successMessage over infoMessage when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + component.infoMessage = INFO_MESSAGE; + + expect(component.statusMessage).toBe(SUCCESS_MESSAGE); + }); + }); + + describe('template', () => { + it('should render the status message when not configured', () => { + component.isConfigured = false; + component.infoMessage = INFO_MESSAGE; + fixture.detectChanges(); + + const message = fixture.nativeElement.querySelector('.setup-step-card__info span'); + expect(message.textContent.trim()).toBe(INFO_MESSAGE); + }); + + it('should render the status message when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + fixture.detectChanges(); + + const message = fixture.nativeElement.querySelector('.setup-step-card__info span'); + expect(message.textContent.trim()).toBe(SUCCESS_MESSAGE); + }); + + it('should render a success icon when configured', () => { + component.isConfigured = true; + component.successMessage = SUCCESS_MESSAGE; + fixture.detectChanges(); + + const icon = fixture.nativeElement.querySelector('cd-icon'); + expect(icon.getAttribute('ng-reflect-type')).toBe('success'); + }); + + it('should render an info icon when not configured', () => { + component.isConfigured = false; + component.infoMessage = INFO_MESSAGE; + fixture.detectChanges(); + + const icon = fixture.nativeElement.querySelector('cd-icon'); + expect(icon.getAttribute('ng-reflect-type')).toBe('infoCircle'); + }); + + it('should render loading panel when isLoading is true', () => { + component.isLoading = true; + fixture.detectChanges(); + + const loadingPanel = fixture.nativeElement.querySelector('cd-loading-panel'); + expect(loadingPanel).toBeTruthy(); + }); + + it('should return "Loading..." as statusMessage when isLoading is true', () => { + component.isLoading = true; + expect(component.statusMessage).toBe('Loading...'); + }); + + it('should apply loading class when isLoading is true', () => { + component.isLoading = true; + fixture.detectChanges(); + + const card = fixture.nativeElement.querySelector('.setup-step-card'); + expect(card.classList.contains('setup-step-card--loading')).toBe(true); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts new file mode 100644 index 00000000000..c77b3b5243b --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/setup-step-card/setup-step-card.component.ts @@ -0,0 +1,33 @@ +import { Component, Input } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ComponentsModule } from '../components.module'; + +@Component({ + selector: 'cd-setup-step-card', + standalone: true, + imports: [CommonModule, ComponentsModule], + templateUrl: './setup-step-card.component.html', + styleUrls: ['./setup-step-card.component.scss'] +}) +export class SetupStepCardComponent { + @Input() stepNumber!: number; + @Input() title!: string; + @Input() description!: string; + @Input() isConfigured: boolean = false; + @Input() isLoading: boolean = false; + @Input() successMessage?: string; + @Input() infoMessage?: string; + + get statusMessage(): string { + if (this.isLoading) { + return $localize`Loading...`; + } + if (this.isConfigured && this.successMessage) { + return this.successMessage; + } + if (!this.isConfigured && this.infoMessage) { + return this.infoMessage; + } + return this.isConfigured ? $localize`Configured successfully.` : $localize`Not configured yet.`; + } +} 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 f5a7297b167..f76c642986f 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 @@ -41,6 +41,9 @@ + + + - - + @if (row.cdExecuting) { + + } + {{ value }} - ({{ row.cdExecuting }}) + @if (row.cdExecuting) { + ({{ row.cdExecuting }}) + }