mgr/dashboard: NVMe Onboarding Setup Cards

Fixes: https://tracker.ceph.com/issues/77067

Signed-off-by: pujaoshahu <pshahu@redhat.com>
This commit is contained in:
pujashahu 2026-06-03 00:05:45 +05:30 committed by pujashahu
parent 7163b736cd
commit 6182e67ea4
32 changed files with 1779 additions and 349 deletions

View File

@ -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',

View File

@ -0,0 +1,22 @@
<div cdsGrid
[useCssGrid]="true"
[narrow]="true"
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{sm: 4, md: 8}">
<div class="form-item"
cdsRow>
<cds-combo-box
type="single"
label="Selected Gateway Group"
i18n-label
[placeholder]="placeholder"
[items]="items"
(selected)="onSelected($event)"
(clear)="onClear()"
[disabled]="disabled">
<cds-dropdown-list></cds-dropdown-list>
</cds-combo-box>
</div>
</div>
</div>

View File

@ -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<NvmeofGatewayGroupFilterComponent>;
let component: NvmeofGatewayGroupFilterComponent;
let nvmeofService: Partial<NvmeofService>;
let routerSpy: Partial<Router>;
let activatedRoute: Partial<ActivatedRoute>;
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();
});
});

View File

@ -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<string | null>();
items: GroupsComboboxItem[] = [];
disabled = false;
placeholder = DEFAULT_PLACEHOLDER;
private destroy$ = new Subject<void>();
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);
}
}

View File

@ -1,5 +1,3 @@
<cd-nvmeof-tabs></cd-nvmeof-tabs>
<ng-container *ngIf="gatewayGroup$ | async as gateways">
<cd-table
#table

View File

@ -3,13 +3,15 @@ import { NO_ERRORS_SCHEMA } from '@angular/core';
import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group.component';
import { GridModule, TabsModule, ModalModule } from 'carbon-components-angular';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { of } from 'rxjs';
import { Observable, of, Subject } from 'rxjs';
import { HttpClientModule } from '@angular/common/http';
import { SharedModule } from '~/app/shared/shared.module';
import { Router } from '@angular/router';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
import { NvmeofGatewayGroupDeleteGuardModalComponent } from './nvmeof-gateway-group-delete-guard-modal.component';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofStateService } from '../nvmeof-state.service';
describe('NvmeofGatewayGroupComponent', () => {
let component: NvmeofGatewayGroupComponent;
@ -22,6 +24,11 @@ describe('NvmeofGatewayGroupComponent', () => {
listSubsystems: jest.fn().mockReturnValue(of([]))
};
const nvmeofStateServiceMock = {
refresh$: new Subject<void>(),
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();
});
});

View File

@ -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<void>();
@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();
}
}

View File

@ -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]);
});

View File

@ -1,27 +1,6 @@
<cd-nvmeof-tabs></cd-nvmeof-tabs>
<div cdsGrid
[useCssGrid]="true"
[narrow]="true"
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{sm: 4, md: 8}">
<div class="cds-mt-3 form-item"
cdsRow>
<cds-combo-box
type="single"
label="Selected Gateway Group"
i18n-label
[placeholder]="gwGroupPlaceholder"
[items]="gwGroups"
(selected)="onGroupSelection($event)"
(clear)="onGroupClear()"
[disabled]="gwGroupsEmpty">
<cds-dropdown-list></cds-dropdown-list>
</cds-combo-box>
</div>
</div>
</div>
<cd-nvmeof-gateway-group-filter
(groupChange)="onGroupChange($event)">
</cd-nvmeof-gateway-group-filter>
<ng-container *ngIf="namespaces$ | async as namespaces">
<cd-table [data]="namespaces"

View File

@ -1,12 +1,13 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientModule } from '@angular/common/http';
import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of } from 'rxjs';
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { of, Subject } from 'rxjs';
import { take } from 'rxjs/operators';
import { RouterTestingModule } from '@angular/router/testing';
import { SharedModule } from '~/app/shared/shared.module';
import { NvmeofService } from '../../../shared/api/nvmeof.service';
import { NvmeofStateService } from '../nvmeof-state.service';
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';
@ -63,6 +64,11 @@ describe('NvmeofNamespacesListComponent', () => {
let modalService: MockModalCdsService;
beforeEach(async () => {
const nvmeofStateServiceMock = {
refresh$: new Subject<void>(),
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', () => {

View File

@ -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<NvmeofSubsystemNamespace[]>;
private namespaceSubject = new BehaviorSubject<void>(undefined);
// Gateway group dropdown properties
gwGroups: GroupsComboboxItem[] = [];
gwGroupsEmpty: boolean = false;
gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER;
private destroy$ = new Subject<void>();
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());
}
onGroupChange(group: string | null): void {
this.group = group;
this.namespaceSubject.next();
}
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
listNamespaces() {
this.namespaceSubject.next();
}
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 <strong>${namespace.nsid}</strong> 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() }))
});
}

View File

@ -0,0 +1,57 @@
<cd-productive-card>
<ng-template #header>
<div cdsStack="vertical"
[gap]="3">
<h2 class="cds--type-heading-03"
i18n>Recommended first-time setup</h2>
<p class="cds--type-body-01"
i18n>
Start your NVMe over Fabrics configuration by creating the essential resources in sequence.
</p>
</div>
</ng-template>
<div cdsStack="horizontal" [gap]="4">
<div>
<cd-setup-step-card
[stepNumber]="1"
[title]="cards.gateway.title"
[description]="cards.gateway.description"
[isConfigured]="hasGatewayGroups"
[successMessage]="cards.gateway.successMessage"
[infoMessage]="cards.gateway.infoMessage">
</cd-setup-step-card>
</div>
<div >
<cd-setup-step-card
[stepNumber]="2"
[title]="cards.subsystem.title"
[description]="cards.subsystem.description"
[isConfigured]="hasSubsystems"
[successMessage]="cards.subsystem.successMessage"
[infoMessage]="hasGatewayGroups ? cards.subsystem.infoMessage : gatewayPendingMessage">
</cd-setup-step-card>
</div>
<div >
<cd-setup-step-card
[stepNumber]="3"
[title]="cards.namespace.title"
[description]="cards.namespace.description"
[isConfigured]="hasNamespaces"
[successMessage]="cards.namespace.successMessage"
[infoMessage]="hasGatewayGroups ? cards.namespace.infoMessage : gatewayPendingMessage">
</cd-setup-step-card>
</div>
</div>
@if (isAllConfigured) {
<div class="nvmeof-setup-cards__completion">
<a cdsLink
class="cds--link--disabled"
aria-disabled="true"
i18n>Configuration complete. View status →</a>
</div>
}
</cd-productive-card>

View File

@ -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<NvmeofSetupCardsComponent>;
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.');
});
});

View File

@ -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.`
}
};
}

View File

@ -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<any>;
let summaryServiceSpy: any;
const emitSummary = (tasks: any[]) => summaryData$.next({ finished_tasks: tasks });
beforeEach(() => {
summaryData$ = new Subject<any>();
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();
});
});

View File

@ -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<void>();
private readonly taskRefreshSource = new Subject<void>();
private summarySubscription: Subscription;
private seenTaskSignatures = new Set<string>();
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
});
}
}

View File

@ -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<void>(),
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();
});

View File

@ -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<void>();
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() }))
});
}

View File

@ -1,27 +1,7 @@
<cd-nvmeof-tabs></cd-nvmeof-tabs>
<cd-nvmeof-gateway-group-filter
(groupChange)="onGroupChange($event)">
</cd-nvmeof-gateway-group-filter>
<div cdsGrid
[useCssGrid]="true"
[narrow]="true"
[fullWidth]="true">
<div cdsCol
[columnNumbers]="{sm: 4, md: 8}">
<div class="cds-mt-3 form-item"
cdsRow>
<cds-combo-box
type="single"
label="Selected Gateway Group"
i18n-label
[placeholder]="gwGroupPlaceholder"
[items]="gwGroups"
(selected)="onGroupSelection($event)"
(clear)="onGroupClear()"
[disabled]="gwGroupsEmpty">
<cds-dropdown-list></cds-dropdown-list>
</cds-combo-box>
</div>
</div>
</div>
<ng-container *ngIf="subsystems$ | async as subsystems">
<cd-table #table
[data]="subsystems"

View File

@ -1,6 +1,8 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientModule } from '@angular/common/http';
import { of } from 'rxjs';
import { ActivatedRoute, Router } from '@angular/router';
import { BehaviorSubject, of, Subject } from 'rxjs';
import { skip, take } from 'rxjs/operators';
import { RouterTestingModule } from '@angular/router/testing';
import { SharedModule } from '~/app/shared/shared.module';
@ -10,8 +12,9 @@ import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component';
import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
import { NvmeofGatewayGroupFilterComponent } from '../nvmeof-gateway-group-filter/nvmeof-gateway-group-filter.component';
import { ComboBoxModule, GridModule } from 'carbon-components-angular';
import { CephServiceSpec } from '~/app/shared/models/service.interface';
import { NvmeofStateService } from '../nvmeof-state.service';
const mockSubsystems = [
{
@ -27,38 +30,27 @@ const mockSubsystems = [
}
];
const mockGroups = [
[
{
service_name: 'nvmeof.rbd.default',
service_type: 'nvmeof',
unmanaged: false,
spec: {
group: 'default'
}
},
{
service_name: 'nvmeof.rbd.foo',
service_type: 'nvmeof',
unmanaged: false,
spec: {
group: 'foo'
}
}
],
2
];
const mockformattedGwGroups = [
{
content: 'default'
},
{
content: 'foo'
}
];
class MockNvmeOfService {
listGatewayGroups() {
return of([
[
{
service_name: 'nvmeof.default',
service_type: 'nvmeof',
service_id: 'default',
spec: { group: 'default' }
}
]
]);
}
formatGwGroupsList(response: any) {
return (response?.[0] || []).map((g: any) => ({
content: g.spec.group,
selected: false
}));
}
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<NvmeofSubsystemsComponent>;
let nvmeofService: MockNvmeOfService;
let queryParams$: BehaviorSubject<Record<string, string>>;
const activatedRouteMock = {
queryParams: null as any,
snapshot: { queryParams: {} as Record<string, string> }
};
beforeEach(async () => {
queryParams$ = new BehaviorSubject<Record<string, string>>({});
activatedRouteMock.queryParams = queryParams$.asObservable();
activatedRouteMock.snapshot.queryParams = queryParams$.value;
const nvmeofStateServiceMock = {
refresh$: new Subject<void>(),
requestRefresh: jest.fn()
};
await TestBed.configureTestingModule({
declarations: [NvmeofSubsystemsComponent, NvmeofSubsystemsDetailsComponent],
imports: [HttpClientModule, RouterTestingModule, SharedModule, ComboBoxModule, GridModule],
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');
});
});

View File

@ -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<void>(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,16 +144,20 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
}),
takeUntil(this.destroy$)
);
this.nvmeofStateService.refresh$
.pipe(takeUntil(this.destroy$))
.subscribe(() => this.fetchData());
}
onGroupChange(group: string | null): void {
this.group = group;
this.subsystemSubject.next();
}
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
getSubsystems() {
this.subsystemSubject.next();
}
fetchData() {
this.subsystemSubject.next();
}
@ -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?.();

View File

@ -1,31 +1,45 @@
<fieldset>
<legend>
<h1 class="cds--type-heading-03">NVMe over Fabrics (TCP)</h1>
<cd-help-text>Monitor and manage NVMe-over-TCP resources for high-performance block storage.</cd-help-text>
</legend>
</fieldset>
<section>
<cds-tabs type="contained"
followFocus="true"
isNavigation="true"
[cacheActive]="false">
<cds-tab
heading="Gateways"
i18n-heading
[active]="activeTab === Tabs.gateways"
(selected)="onSelected(Tabs.gateways)">
</cds-tab>
<cds-tab
heading="Subsystems"
i18n-heading
[active]="activeTab === Tabs.subsystems"
(selected)="onSelected(Tabs.subsystems)">
</cds-tab>
<cds-tab
heading="Namespaces"
i18n-heading
[active]="activeTab === Tabs.namespaces"
(selected)="onSelected(Tabs.namespaces)">
</cds-tab>
</cds-tabs>
</section>
@if (showTabsShell) {
<fieldset>
<legend class="cds-mb-5">
<h1 class="cds--type-heading-04">NVMe over Fabrics (TCP)</h1>
<p class="cds--type-body-01" i18n>Monitor and manage NVMe-over-TCP resources for high-performance block storage.</p>
</legend>
</fieldset>
@if (showSetupCards) {
<cd-nvmeof-setup-cards
[hasGatewayGroups]="hasGatewayGroups"
[hasSubsystems]="hasSubsystems"
[hasNamespaces]="hasNamespaces"
[isAllConfigured]="isAllConfigured">
</cd-nvmeof-setup-cards>
}
<section class="cds-mt-5">
<cds-tabs type="contained"
followFocus="true"
isNavigation="true"
[cacheActive]="false">
<cds-tab
heading="Gateways"
i18n-heading
[active]="activeTab === Tabs.gateways"
(selected)="onSelected(Tabs.gateways)">
</cds-tab>
<cds-tab
heading="Subsystems"
i18n-heading
[active]="activeTab === Tabs.subsystems"
(selected)="onSelected(Tabs.subsystems)">
</cds-tab>
<cds-tab
heading="Namespaces"
i18n-heading
[active]="activeTab === Tabs.namespaces"
(selected)="onSelected(Tabs.namespaces)">
</cds-tab>
</cds-tabs>
</section>
}
<router-outlet></router-outlet>

View File

@ -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<NvmeofTabsComponent>;
let router: Router;
let nvmeofServiceSpy: any;
let queryParams$: BehaviorSubject<any>;
let refresh$: Subject<void>;
let routerEvents$: Subject<RouterEvent>;
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<any>({ group: 'grp1' });
refresh$ = new Subject<void>();
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<RouterEvent>();
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.'
);
});
});
});

View File

@ -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 {

View File

@ -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<T>(response: unknown, key: string): T[] {
if (Array.isArray(response)) {
return response as T[];
}
const nested = (response as Record<string, T[]> | null)?.[key];
return Array.isArray(nested) ? nested : [];
}
fetchSetupState(): Observable<SetupState> {
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<NvmeofSubsystem>(resp, 'subsystems')),
catchError(() => observableOf([]))
),
namespaces: this.listNamespaces(firstGroupName).pipe(
map((resp: unknown) =>
this.normalizeListResponse<NvmeofSubsystemNamespace>(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<CephServiceSpec[][]>(`${API_PATH}/gateway/group`);

View File

@ -0,0 +1,20 @@
<div class="setup-step-card" [class.setup-step-card--loading]="isLoading">
<div class="setup-step-card__step-row">
<span class="cds--type-heading-compact-01">{{ stepNumber }}.</span>
<span class="cds--type-heading-compact-01">{{ title }}</span>
</div>
<p class="cds--type-label-01">
{{ description }}
</p>
<div class="setup-step-card__info">
@if (isLoading) {
<cd-loading-panel></cd-loading-panel>
} @else if (isConfigured) {
<cd-icon type="success"></cd-icon>
<span class="cds--type-label-01">{{ statusMessage }}</span>
} @else {
<cd-icon type="infoCircle"></cd-icon>
<span class="cds--type-label-01">{{ statusMessage }}</span>
}
</div>
</div>

View File

@ -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);
}
}

View File

@ -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<SetupStepCardComponent>;
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);
});
});
});

View File

@ -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.`;
}
}

View File

@ -41,6 +41,9 @@
</cds-table-toolbar-actions>
<!-- end batch actions -->
<cds-table-toolbar-content>
<!-- gateway group / custom filter slot -->
<ng-content select=".table-filter"></ng-content>
<!-- end custom filter slot -->
<!-- search -->
<cds-table-toolbar-search *ngIf="searchField"
[expandable]="false"
@ -484,13 +487,15 @@
let-column="data.column"
let-row="data.row"
let-value="data.value">
<cds-inline-loading *ngIf="row.cdExecuting"
state="active"></cds-inline-loading>
<span [ngClass]="column?.customTemplateConfig?.valueClass">
@if (row.cdExecuting) {
<cds-inline-loading></cds-inline-loading>
}
<span [ngClass]="column?.customTemplateConfig?.valueClass || ''">
{{ value }}
</span>
<span *ngIf="row.cdExecuting"
[ngClass]="column?.customTemplateConfig?.executingClass ? column?.customTemplateConfig.executingClass : 'text-muted italic'">({{ row.cdExecuting }})</span>
@if (row.cdExecuting) {
<span [ngClass]="column?.customTemplateConfig?.executingClass ? column?.customTemplateConfig.executingClass : 'text-muted italic'">({{ row.cdExecuting }})</span>
}
</ng-template>
<ng-template #classAddingTpl
let-value="data.value">