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: [
ContainedListModule,
SideNavModule,
LayoutModule,
- ThemeModule
+ ThemeModule,
+ NvmeofSetupCardsComponent,
+ NvmeofGatewayGroupFilterComponent
],
declarations: [
RbdListComponent,
{
path: 'nvmeof',
canActivate: [ModuleStatusGuardService],
+ component: NvmeofTabsComponent,
data: {
breadcrumbs: true,
text: 'NVMe/TCP',
--- /dev/null
+<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>
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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);
+ }
+}
-<cd-nvmeof-tabs></cd-nvmeof-tabs>
-
<ng-container *ngIf="gatewayGroup$ | async as gateways">
<cd-table
#table
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;
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],
{
provide: ModalCdsService,
useValue: { show: jest.fn() }
- }
+ },
+ { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents();
);
});
});
+
+ 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();
+ });
});
-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';
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';
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;
gatewayGroupName: string;
subsystemCount: number;
gatewayCount: number;
+ private lastGroupCount = 0;
viewUrl = `/${BASE_URL}/view`;
icons = Icons;
public taskWrapper: TaskWrapperService,
private notificationService: NotificationService,
private urlBuilder: URLBuilderService,
- private router: Router
+ private router: Router,
+ private nvmeofStateService: NvmeofStateService
) {}
ngOnInit(): void {
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([]);
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())
);
}
});
}
this.router.navigate([this.viewUrl, groupName]);
}
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
}
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]);
});
-<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"
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';
let modalService: MockModalCdsService;
beforeEach(async () => {
+ const nvmeofStateServiceMock = {
+ refresh$: new Subject<void>(),
+ requestRefresh: jest.fn()
+ };
+
await TestBed.configureTestingModule({
declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent],
imports: [HttpClientModule, RouterTestingModule, SharedModule],
{ 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);
);
done();
});
- component.listNamespaces();
+ component.fetchData();
});
it('should open delete modal with correct data', () => {
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';
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',
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(
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`,
}),
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;
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() }))
});
}
--- /dev/null
+<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>
--- /dev/null
+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.');
+ });
+});
--- /dev/null
+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.`
+ }
+ };
+}
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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
+ });
+ }
+}
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';
}
beforeEach(async () => {
+ const nvmeofStateServiceMock = {
+ refresh$: new Subject<void>(),
+ requestRefresh: jest.fn()
+ };
+
await TestBed.configureTestingModule({
declarations: [NvmeofSubsystemNamespacesListComponent],
imports: [HttpClientTestingModule, RouterTestingModule, SharedModule],
listNamespaces: jest.fn().mockReturnValue(of(mockNamespaces))
}
},
- { provide: AuthStorageService, useClass: MockAuthStorageService }
+ { provide: AuthStorageService, useClass: MockAuthStorageService },
+ { provide: NvmeofStateService, useValue: nvmeofStateServiceMock }
]
}).compileComponents();
});
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',
private destroy$ = new Subject<void>();
constructor(
- // ... constructor stays mostly same
public actionLabels: ActionLabelsI18n,
private router: Router,
private modalService: ModalCdsService,
private nvmeofService: NvmeofService,
private dimlessBinaryPipe: DimlessBinaryPipe,
private iopsPipe: IopsPipe,
- private route: ActivatedRoute
+ private route: ActivatedRoute,
+ private nvmeofStateService: NvmeofStateService
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
}
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 || [];
});
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() }))
});
}
-<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"
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';
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 = [
{
}
];
-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
-];
+class MockNvmeOfService {
+ listGatewayGroups() {
+ return of([
+ [
+ {
+ service_name: 'nvmeof.default',
+ service_type: 'nvmeof',
+ service_id: 'default',
+ spec: { group: 'default' }
+ }
+ ]
+ ]);
+ }
-const mockformattedGwGroups = [
- {
- content: 'default'
- },
- {
- content: 'foo'
+ formatGwGroupsList(response: any) {
+ return (response?.[0] || []).map((g: any) => ({
+ content: g.spec.group,
+ selected: false
+ }));
}
-];
-class MockNvmeOfService {
listSubsystems() {
return of(mockSubsystems);
}
getInitiators() {
return of([]);
}
-
- formatGwGroupsList(_data: CephServiceSpec[][]) {
- return mockformattedGwGroups;
- }
-
- listGatewayGroups() {
- return of(mockGroups);
- }
}
class MockAuthStorageService {
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();
});
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');
});
});
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 {
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',
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);
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`,
}),
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() {
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?.();
-<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>
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', () => {
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', () => {
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.'
+ );
+ });
+ });
});
-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';
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 {
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;
}, []);
}
+ 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`);
--- /dev/null
+<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>
--- /dev/null
+.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);
+ }
+}
--- /dev/null
+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);
+ });
+ });
+});
--- /dev/null
+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.`;
+ }
+}
</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"
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">