mirror of
https://github.com/ceph/ceph
synced 2026-08-02 07:03:18 +00:00
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:
parent
7163b736cd
commit
6182e67ea4
@ -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',
|
||||
|
||||
@ -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>
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,3 @@
|
||||
<cd-nvmeof-tabs></cd-nvmeof-tabs>
|
||||
|
||||
<ng-container *ngIf="gatewayGroup$ | async as gateways">
|
||||
<cd-table
|
||||
#table
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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() }))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
@ -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.');
|
||||
});
|
||||
});
|
||||
@ -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.`
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
});
|
||||
|
||||
@ -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() }))
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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?.();
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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`);
|
||||
|
||||
@ -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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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.`;
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
Loading…
Reference in New Issue
Block a user