LayoutModule,
ContainedListModule,
LayerModule,
- ThemeModule
+ ThemeModule,
+ LayoutModule
} from 'carbon-components-angular';
// Icons
import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
+import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
@NgModule({
imports: [
LayoutModule,
ContainedListModule,
SideNavModule,
- ThemeModule
+ ThemeModule,
+ LayoutModule
],
declarations: [
RbdListComponent,
NvmeofSubsystemsStepTwoComponent,
NvmeofSubsystemsStepThreeComponent,
NvmeGatewayViewComponent,
- NvmeofGatewaySubsystemComponent
+ NvmeofGatewaySubsystemComponent,
+ NvmeofGatewayNodeAddModalComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
--- /dev/null
+<cds-modal
+ [open]="open"
+ (overlaySelected)="closeModal()"
+ [size]="'lg'">
+ <cds-modal-header
+ (closeSelect)="closeModal()">
+ <h4
+ class="cds--type-heading-04 cds-ml-3"
+ i18n>
+ Add gateway nodes
+ </h4>
+ <p
+ class="cds--type-body-compact-01 cds-ml-3"
+ i18n>
+ Select NVMe-oF gateway nodes to associate with this gateway group.
+ </p>
+ </cds-modal-header>
+ <section cdsModalContent>
+
+ <div class="cds-mt-5">
+ <div
+ cdsStack="vertical"
+ gap="1">
+ <div
+ class="cds--type-heading-01"
+ i18n>
+ Select gateway nodes
+ </div>
+ <div
+ class="cds--type-label-01"
+ i18n>
+ Nodes to run NVMe-oF target pods/services
+ </div>
+ </div>
+
+ <cd-table
+ (fetchData)="getHosts($event)"
+ selectionType="multiClick"
+ [searchableObjects]="true"
+ [data]="hosts"
+ [columns]="columns"
+ (updateSelection)="updateSelection($event)"
+ [autoReload]="false">
+ </cd-table>
+
+ <ng-template
+ #addrTemplate
+ let-value="data.value">
+ <span>{{ value || '-' }}</span>
+ </ng-template>
+
+ <ng-template
+ #statusTemplate
+ let-value="data.value"
+ let-row="data.row">
+ <div
+ class="status-cell"
+ cdsStack="horizontal"
+ gap="3">
+ @if (value === HostStatus.AVAILABLE) {
+ <cd-icon type="success"></cd-icon>
+ }
+ <span>{{ value | titlecase }}</span>
+ </div>
+ </ng-template>
+
+ <ng-template
+ #labelsTemplate
+ let-value="data.value">
+ @if (value && value.length > 0) {
+ <cds-tag
+ *ngFor="let label of value"
+ class="tag tag-dark">{{ label }}</cds-tag>
+ } @else {
+ <span>-</span>
+ }
+ </ng-template>
+
+ </div>
+ </section>
+ <cd-form-button-panel
+ [modalForm]="true"
+ [showSubmit]="true"
+ submitText="Add"
+ i18n-submitText
+ [disabled]="selection.selected.length === 0"
+ (submitActionEvent)="onSubmit()"
+ (backActionEvent)="closeModal()">
+ </cd-form-button-panel>
+</cds-modal>
--- /dev/null
+cds-modal-header {
+ border-bottom: 1px solid var(--cds-ui-03, #e0e0e0);
+}
+
+.status-cell {
+ align-items: center;
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node-add-modal.component';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RouterTestingModule } from '@angular/router/testing';
+import { HostService } from '~/app/shared/api/host.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { TaskMessageService } from '~/app/shared/services/task-message.service';
+import { of } from 'rxjs';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+
+describe('NvmeofGatewayNodeAddModalComponent', () => {
+ let component: NvmeofGatewayNodeAddModalComponent;
+ let fixture: ComponentFixture<NvmeofGatewayNodeAddModalComponent>;
+
+ const mockHostService = {
+ checkHostsFactsAvailable: jest.fn().mockReturnValue(true),
+ list: jest.fn().mockReturnValue(of([{ hostname: 'host1' }, { hostname: 'host2' }]))
+ };
+
+ const mockNvmeofService = {
+ getAvailableHosts: jest.fn().mockReturnValue(of([{ hostname: 'host2' }]))
+ };
+
+ const mockCephServiceService = {
+ update: jest.fn().mockReturnValue(of({}))
+ };
+
+ const mockNotificationService = {
+ show: jest.fn()
+ };
+
+ const mockTaskMessageService = {
+ messages: {
+ 'nvmeof/gateway/node/add': {
+ success: jest.fn().mockReturnValue('Success'),
+ failure: jest.fn().mockReturnValue('Failure')
+ }
+ }
+ };
+
+ const mockServiceSpec = {
+ placement: {
+ hosts: ['host1']
+ }
+ };
+
+ configureTestBed({
+ imports: [SharedModule, HttpClientTestingModule, RouterTestingModule],
+ declarations: [NvmeofGatewayNodeAddModalComponent],
+ providers: [
+ { provide: HostService, useValue: mockHostService },
+ { provide: NvmeofService, useValue: mockNvmeofService },
+ { provide: CephServiceService, useValue: mockCephServiceService },
+ { provide: NotificationService, useValue: mockNotificationService },
+ { provide: TaskMessageService, useValue: mockTaskMessageService },
+ { provide: 'groupName', useValue: 'group1' },
+ { provide: 'usedHostnames', useValue: ['host1'] },
+ { provide: 'serviceSpec', useValue: mockServiceSpec }
+ ]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeofGatewayNodeAddModalComponent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should load attributes', () => {
+ expect(component.groupName).toBe('group1');
+ expect(component.usedHostnames).toEqual(['host1']);
+ expect(component.serviceSpec).toEqual(mockServiceSpec);
+ });
+
+ it('should load hosts using NvmeofService', () => {
+ const context = new CdTableFetchDataContext(() => undefined);
+ component.getHosts(context);
+
+ expect(mockNvmeofService.getAvailableHosts).toHaveBeenCalled();
+ expect(component.hosts.length).toBe(1);
+ expect(component.hosts[0].hostname).toBe('host2');
+ });
+
+ it('should add gateway node', () => {
+ component.selection.selected = [{ hostname: 'host2' }];
+ component.onSubmit();
+
+ expect(mockCephServiceService.update).toHaveBeenCalledWith({
+ placement: { hosts: ['host1', 'host2'] }
+ });
+ expect(mockNotificationService.show).toHaveBeenCalled();
+ });
+});
--- /dev/null
+import {
+ Component,
+ EventEmitter,
+ OnInit,
+ ViewChild,
+ TemplateRef,
+ OnDestroy,
+ Inject
+} from '@angular/core';
+import { Subscription } from 'rxjs';
+
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { Host } from '~/app/shared/models/host.interface';
+import { HostStatus } from '~/app/shared/enum/host-status.enum';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { CephServiceSpec, CephServiceSpecUpdate } from '~/app/shared/models/service.interface';
+import { TaskMessageService } from '~/app/shared/services/task-message.service';
+
+@Component({
+ selector: 'cd-nvmeof-gateway-node-add-modal',
+ templateUrl: './nvmeof-gateway-node-add-modal.component.html',
+ styleUrls: ['./nvmeof-gateway-node-add-modal.component.scss'],
+ standalone: false
+})
+export class NvmeofGatewayNodeAddModalComponent extends CdForm implements OnInit, OnDestroy {
+ hosts: Host[] = [];
+ columns: CdTableColumn[] = [];
+ selection = new CdTableSelection();
+ public gatewayAdded = new EventEmitter<void>();
+ isLoadingHosts = false;
+ private tableContext: CdTableFetchDataContext = null;
+ private sub = new Subscription();
+ private readonly ADD_GATEWAY_NODE_TASK = 'nvmeof/gateway/node/add';
+
+ @ViewChild('statusTemplate', { static: true })
+ statusTemplate!: TemplateRef<any>;
+
+ @ViewChild('labelsTemplate', { static: true })
+ labelsTemplate!: TemplateRef<any>;
+
+ @ViewChild('addrTemplate', { static: true })
+ addrTemplate!: TemplateRef<any>;
+
+ HostStatus = HostStatus;
+
+ constructor(
+ private nvmeofService: NvmeofService,
+ private cephServiceService: CephServiceService,
+ private notificationService: NotificationService,
+ private taskMessageService: TaskMessageService,
+ @Inject('groupName') public groupName: string,
+ @Inject('usedHostnames') public usedHostnames: string[],
+ @Inject('serviceSpec') public serviceSpec: CephServiceSpec
+ ) {
+ super();
+ }
+
+ ngOnInit(): void {
+ this.columns = [
+ {
+ name: $localize`Hostname`,
+ prop: 'hostname',
+ flexGrow: 2
+ },
+ {
+ name: $localize`IP address`,
+ prop: 'addr',
+ flexGrow: 2,
+ cellTemplate: this.addrTemplate
+ },
+ {
+ name: $localize`Status`,
+ prop: 'status',
+ flexGrow: 1,
+ cellTemplate: this.statusTemplate
+ },
+ {
+ name: $localize`Labels (tags)`,
+ prop: 'labels',
+ flexGrow: 3,
+ cellTemplate: this.labelsTemplate
+ }
+ ];
+ }
+
+ ngOnDestroy(): void {
+ this.sub.unsubscribe();
+ }
+
+ getHosts(context: CdTableFetchDataContext) {
+ if (context !== null) {
+ this.tableContext = context;
+ }
+ if (this.tableContext == null) {
+ this.tableContext = new CdTableFetchDataContext(() => undefined);
+ }
+ if (this.isLoadingHosts) {
+ return;
+ }
+ this.isLoadingHosts = true;
+
+ this.sub.add(
+ this.nvmeofService.getAvailableHosts(this.tableContext?.toParams()).subscribe({
+ next: (hostList: Host[]) => {
+ this.hosts = hostList;
+ this.isLoadingHosts = false;
+ },
+ error: () => {
+ this.isLoadingHosts = false;
+ context.error();
+ }
+ })
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ onSubmit() {
+ if (!this.serviceSpec) {
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Service specification is missing.`
+ );
+ return;
+ }
+
+ this.loadingStart();
+
+ const modifiedSpec = this.createServiceSpecPayload();
+
+ this.cephServiceService.update(modifiedSpec).subscribe({
+ next: () => {
+ this.notificationService.show(
+ NotificationType.success,
+ this.taskMessageService.messages[this.ADD_GATEWAY_NODE_TASK].success({
+ group_name: this.groupName
+ })
+ );
+ this.gatewayAdded.emit();
+ this.loadingReady();
+ this.closeModal();
+ },
+ error: (e) => {
+ this.loadingReady();
+ this.notificationService.show(
+ NotificationType.error,
+ this.taskMessageService.messages[this.ADD_GATEWAY_NODE_TASK].failure({
+ group_name: this.groupName
+ }),
+ e
+ );
+ }
+ });
+ }
+
+ private createServiceSpecPayload(): CephServiceSpecUpdate {
+ const selectedHosts = this.selection.selected.map((h: Host) => h.hostname);
+ const currentHosts = this.serviceSpec.placement?.hosts || [];
+ const newHosts = [...currentHosts, ...selectedHosts];
+
+ const { status, ...modifiedSpec } = this.serviceSpec;
+
+ if ('events' in modifiedSpec) {
+ delete (modifiedSpec as any).events;
+ }
+
+ if (modifiedSpec.placement) {
+ modifiedSpec.placement = { ...modifiedSpec.placement };
+ } else {
+ modifiedSpec.placement = {};
+ }
+
+ modifiedSpec.placement.hosts = newHosts;
+
+ return modifiedSpec;
+ }
+}
[maxLimit]="25"
identifier="hostname"
forceIdentifier="true"
+ [autoReload]="false"
(updateSelection)="updateSelection($event)"
+ emptyStateTitle="No nodes available"
+ i18n-emptyStateTitle
+ emptyStateMessage="Add your first gateway node to start using NVMe over Fabrics. Nodes provide the resources required to expose NVMe/TCP block storage."
+ i18n-emptyStateMessage
>
<cd-table-actions
class="table-actions"
</cd-table>
</div>
+<ng-template
+ #hostNameTpl
+ let-value="data.value"
+>
+ <span class="cds-ml-2">{{ value }}</span>
+</ng-template>
+
<ng-template
#addrTpl
let-value="data.value"
>
<span>{{ value || '-' }}</span>
-
</ng-template>
<ng-template
[cdsStack]="'horizontal'"
gap="4"
>
- @if (value === HostStatus.AVAILABLE) {
+ @if (value === HostStatus.AVAILABLE || value === HostStatus.RUNNING) {
<cd-icon type="success"></cd-icon>
+ } @else {
+ <cd-icon type="error"></cd-icon>
}
<span class="cds-ml-3">{{ value | titlecase }}</span>
</div>
}
</ng-template>
-<ng-template #labelsTpl
- let-value="data.value">
+<ng-template
+ #labelsTpl
+ let-value="data.value"
+>
@if (value && value.length > 0) {
<cds-tag *ngFor="let label of value"
class="tag tag-dark">{{ label }}</cds-tag>
provide: ActivatedRoute,
useValue: {
parent: {
- params: new BehaviorSubject({ group: 'group1' })
+ params: new BehaviorSubject({ group: 'group1' }),
+ snapshot: {
+ params: { group: 'group1' }
+ }
},
- data: of({ mode: 'selector' }),
snapshot: {
data: { mode: 'selector' }
}
it('should initialize with default values', () => {
expect(component.hosts).toEqual([]);
expect(component.isLoadingHosts).toBe(false);
- expect(component.totalHostCount).toBe(5);
+ expect(component.count).toBe(0);
expect(component.permission).toBeDefined();
});
});
it('should load hosts with orchestrator available and facts feature enabled', fakeAsync(() => {
- const hostListSpy = spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
- const mockOrcStatus: any = {
- available: true,
- features: new Map([['get_facts', { available: true }]])
- };
-
- spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
- of([
- [
- {
- service_id: 'nvmeof.group1',
- placement: { hosts: ['gateway-node-1'] }
- }
- ]
- ] as any)
- );
- spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
- component.groupName = 'group1';
- fixture.detectChanges();
-
+ spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of([mockGatewayNodes[2]]));
component.getHosts(new CdTableFetchDataContext(() => undefined));
tick(100);
- expect(hostListSpy).toHaveBeenCalled();
- // Hosts NOT in usedHostnames are included (gateway-node-1 is used, so filtered out)
- // gateway-node-2 and gateway-node-3 are returned (status is not filtered)
- expect(component.hosts.length).toBe(2);
- expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-2');
- expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-3');
+ expect(nvmeofService.getAvailableHosts).toHaveBeenCalled();
+ expect(component.hosts.length).toBe(1);
+ expect(component.hosts[0]['hostname']).toBe('gateway-node-3');
}));
it('should set count to hosts length', fakeAsync(() => {
- spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
- const mockOrcStatus: any = {
- available: true,
- features: new Map()
- };
-
- spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
- of([
- [
- {
- service_id: 'nvmeof.group1',
- placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
- }
- ]
- ] as any)
- );
- spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
- component.groupName = 'group1';
- fixture.detectChanges();
-
+ spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of(mockGatewayNodes));
component.getHosts(new CdTableFetchDataContext(() => undefined));
tick(100);
- // Count should equal the filtered hosts length
- expect(component.totalHostCount).toBe(component.hosts.length);
+ expect(component.count).toBe(component.hosts.length);
}));
it('should set count to 0 when no hosts are returned', fakeAsync(() => {
- spyOn(hostService, 'list').and.returnValue(of([]));
- const mockOrcStatus: any = {
- available: true,
- features: new Map()
- };
-
- spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
- of([
- [
- {
- service_id: 'nvmeof.group1',
- placement: { hosts: ['gateway-node-1'] }
- }
- ]
- ] as any)
- );
- spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
- component.groupName = 'group1';
- fixture.detectChanges();
-
+ spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(of([]));
component.getHosts(new CdTableFetchDataContext(() => undefined));
tick(100);
- expect(component.totalHostCount).toBe(0);
+ expect(component.count).toBe(0);
expect(component.hosts.length).toBe(0);
}));
it('should handle error when fetching hosts', fakeAsync(() => {
- const errorMsg = 'Failed to fetch hosts';
- spyOn(hostService, 'list').and.returnValue(throwError(() => new Error(errorMsg)));
- const mockOrcStatus: any = {
- available: true,
- features: new Map()
- };
-
- spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
- of([
- [
- {
- service_id: 'nvmeof.group1',
- placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
- }
- ]
- ] as any)
- );
- spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
- component.groupName = 'group1';
- fixture.detectChanges();
-
+ spyOn(nvmeofService, 'getAvailableHosts').and.returnValue(throwError(() => new Error('Error')));
const context = new CdTableFetchDataContext(() => undefined);
spyOn(context, 'error');
it('should not re-fetch if already loading', fakeAsync(() => {
component.isLoadingHosts = true;
- const hostListSpy = spyOn(hostService, 'list');
+ spyOn(nvmeofService, 'getAvailableHosts');
component.getHosts(new CdTableFetchDataContext(() => undefined));
tick(100);
- expect(hostListSpy).not.toHaveBeenCalled();
+ expect(nvmeofService.getAvailableHosts).not.toHaveBeenCalled();
}));
it('should unsubscribe on component destroy', fakeAsync(() => {
}));
it('should fetch data using fetchHostsAndGroups in details mode', fakeAsync(() => {
- (component as any).route.data = of({ mode: 'details' });
+ (component as any).route.snapshot.data = { mode: 'details' };
component.ngOnInit();
component.groupName = 'group1';
expect(nvmeofService.fetchHostsAndGroups).toHaveBeenCalled();
expect(component.hosts.length).toBe(1);
expect(component.hosts[0].hostname).toBe('gateway-node-1');
- expect(component.hosts[0].hostname).toBe('gateway-node-1');
}));
it('should set selectionType to multiClick in selector mode', () => {
- (component as any).route.data = of({ mode: 'selector' });
+ (component as any).route.snapshot.data = { mode: 'selector' };
component.ngOnInit();
expect(component.selectionType).toBe('multiClick');
});
it('should set selectionType to single in details mode', () => {
- (component as any).route.data = of({ mode: 'details' });
+ (component as any).route.snapshot.data = { mode: 'details' };
component.ngOnInit();
expect(component.selectionType).toBe('single');
});
ViewChild
} from '@angular/core';
import { ActivatedRoute } from '@angular/router';
-import { forkJoin, Subject, Subscription } from 'rxjs';
-import { finalize, mergeMap } from 'rxjs/operators';
+import { Observable, Subject, Subscription } from 'rxjs';
+import { finalize } from 'rxjs/operators';
-import { HostService } from '~/app/shared/api/host.service';
-import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { HostStatus } from '~/app/shared/enum/host-status.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
-import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
-
import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
import { CephServiceSpec } from '~/app/shared/models/service.interface';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
@Component({
selector: 'cd-nvmeof-gateway-node',
@Output() selectionChange = new EventEmitter<CdTableSelection>();
@Output() hostsLoaded = new EventEmitter<number>();
+
@Input() groupName: string | undefined;
- @Input() mode: NvmeofGatewayNodeMode = NvmeofGatewayNodeMode.SELECTOR;
+ @Input() mode: 'selector' | 'details' = 'selector';
usedHostnames: Set<string> = new Set();
serviceSpec: CephServiceSpec | undefined;
+ hasAvailableHosts = false;
permission: Permission;
columns: CdTableColumn[] = [];
icons = Icons;
HostStatus = HostStatus;
private tableContext: CdTableFetchDataContext | undefined;
- totalHostCount = 5;
+ count = 0;
orchStatus: OrchestratorStatus | undefined;
private destroy$ = new Subject<void>();
private sub: Subscription | undefined;
constructor(
private authStorageService: AuthStorageService,
- private hostService: HostService,
- private orchService: OrchestratorService,
private nvmeofService: NvmeofService,
- private route: ActivatedRoute
+ private route: ActivatedRoute,
+ private modalService: ModalCdsService
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit(): void {
- this.route.data.subscribe((data) => {
- if (data?.['mode']) {
- this.mode = data['mode'];
- }
- });
+ const routeData = this.route.snapshot.data;
+ if (routeData?.['mode']) {
+ this.mode = routeData['mode'];
+ }
- this.selectionType = this.mode === NvmeofGatewayNodeMode.SELECTOR ? 'multiClick' : 'single';
+ this.selectionType = this.mode === 'selector' ? 'multiClick' : 'single';
- if (this.mode === NvmeofGatewayNodeMode.DETAILS) {
- this.route.parent?.params.subscribe((params: { group: string }) => {
+ if (this.mode === 'details') {
+ this.route.parent?.params.subscribe((params: any) => {
this.groupName = params.group;
});
- this.tableActions = [
- {
- permission: 'create',
- icon: Icons.add,
- click: () => this.addGateway(),
- name: $localize`Add`,
- canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
- },
- {
- permission: 'delete',
- icon: Icons.destroy,
- click: () => this.removeGateway(),
- name: $localize`Remove`,
- disable: (selection: CdTableSelection) => !selection.hasSelection
- }
- ];
+ this.setTableActions();
}
this.columns = [
];
}
+ private setTableActions() {
+ this.tableActions = [
+ {
+ permission: 'create',
+ icon: Icons.add,
+ click: () => this.addGateway(),
+ name: $localize`Add`,
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+ disable: () => (!this.hasAvailableHosts ? $localize`No available nodes to add` : false)
+ },
+ {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.removeGateway(),
+ name: $localize`Remove`,
+ disable: (selection: CdTableSelection) => !selection.hasSelection
+ }
+ ];
+ }
+
addGateway(): void {
- // TODO
+ const modalRef = this.modalService.show(NvmeofGatewayNodeAddModalComponent, {
+ groupName: this.groupName,
+ usedHostnames: Array.from(this.usedHostnames),
+ serviceSpec: this.serviceSpec
+ });
+
+ modalRef.gatewayAdded.subscribe(() => {
+ this.table.refreshBtn();
+ });
}
removeGateway(): void {
this.sub.unsubscribe();
}
- const fetchData$ =
- this.mode === NvmeofGatewayNodeMode.DETAILS
+ const fetchData$: Observable<any> =
+ this.mode === 'details'
? this.nvmeofService.fetchHostsAndGroups()
- : forkJoin({
- groups: this.nvmeofService.listGatewayGroups(),
- hosts: this.orchService.status().pipe(
- mergeMap((orchStatus: OrchestratorStatus) => {
- this.orchStatus = orchStatus;
- const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus);
- return this.hostService.list(
- this.tableContext?.toParams(),
- factsAvailable.toString()
- );
- })
- )
- });
+ : this.nvmeofService.getAvailableHosts(this.tableContext?.toParams());
this.sub = fetchData$
.pipe(
)
.subscribe({
next: (result: any) => {
- this.mode === NvmeofGatewayNodeMode.DETAILS
- ? this.processHostsForDetailsMode(result.groups, result.hosts)
- : this.processHostsForSelectorMode(result.groups, result.hosts);
+ if (this.mode === 'details') {
+ this.processDetailsData(result.groups, result.hosts);
+ } else {
+ this.hosts = result;
+ this.count = this.hosts.length;
+ this.hostsLoaded.emit(this.count);
+ }
},
error: () => context?.error()
});
}
- /**
- * Selector Mode: Used in 'Add/Create' forms.
- * Filters the entire cluster inventory to show only **available** candidates
- * (excluding nodes that are already part of a gateway group).
- */
- private processHostsForSelectorMode(groups: CephServiceSpec[][] = [[]], hostList: Host[] = []) {
- const usedHosts = new Set<string>();
- (groups?.[0] ?? []).forEach((group: CephServiceSpec) => {
- group.placement?.hosts?.forEach((hostname: string) => usedHosts.add(hostname));
+ private processDetailsData(groups: any[][], hostList: Host[]) {
+ const groupList = groups?.[0] ?? [];
+
+ const allUsedHostnames = new Set<string>();
+ groupList.forEach((group: CephServiceSpec) => {
+ const hosts = group.placement?.hosts || (group.spec as any)?.placement?.hosts || [];
+ hosts.forEach((hostname: string) => allUsedHostnames.add(hostname));
});
- this.usedHostnames = usedHosts;
- this.hosts = (hostList || []).filter((host: Host) => !this.usedHostnames.has(host.hostname));
+ this.usedHostnames = allUsedHostnames;
- this.updateCount();
- }
-
- /**
- * Details Mode: Used in 'Details' views.
- * Filters specifically for the nodes that are **configured members**
- * of the current gateway group, regardless of their status.
- */
- private processHostsForDetailsMode(groups: any[][], hostList: Host[]) {
- const groupList = groups?.[0] ?? [];
- const currentGroup: CephServiceSpec | undefined = groupList.find(
- (group: CephServiceSpec) => group.spec?.group === this.groupName
+ // Check if there are any available hosts globally (not used by any group)
+ this.hasAvailableHosts = (hostList || []).some(
+ (host: Host) => !this.usedHostnames.has(host.hostname)
);
+ this.setTableActions();
+
+ const currentGroup = groupList.find((group: CephServiceSpec) => {
+ return (
+ group.spec?.group === this.groupName ||
+ group.service_id === `nvmeof.${this.groupName}` ||
+ group.service_id.endsWith(`.${this.groupName}`)
+ );
+ });
- if (!currentGroup) {
+ this.serviceSpec = currentGroup as CephServiceSpec;
+
+ if (!this.serviceSpec) {
this.hosts = [];
} else {
const placementHosts =
- currentGroup.placement?.hosts || (currentGroup.spec as any)?.placement?.hosts || [];
+ this.serviceSpec.placement?.hosts || (this.serviceSpec.spec as any)?.placement?.hosts || [];
const currentGroupHosts = new Set<string>(placementHosts);
this.hosts = (hostList || []).filter((host: Host) => {
});
}
- this.serviceSpec = currentGroup;
- this.updateCount();
- }
-
- private updateCount(): void {
- this.totalHostCount = this.hosts.length;
- this.hostsLoaded.emit(this.totalHostCount);
+ this.count = this.hosts.length;
+ this.hostsLoaded.emit(this.count);
}
}
import { TestBed } from '@angular/core/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { configureTestBed } from '~/testing/unit-test-helper';
-import { NvmeofService } from '../../shared/api/nvmeof.service';
-import { throwError } from 'rxjs';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { HostService } from './host.service';
+import { OrchestratorService } from './orchestrator.service';
+import { of, throwError } from 'rxjs';
describe('NvmeofService', () => {
let service: NvmeofService;
let httpTesting: HttpTestingController;
const mockGroupName = 'default';
const mockNQN = 'nqn.2001-07.com.ceph:1721041732363';
+ const mockHostService = {
+ checkHostsFactsAvailable: jest.fn(),
+ list: jest.fn(),
+ getAllHosts: jest.fn()
+ };
+
+ const mockOrchService = {
+ status: jest.fn()
+ };
const UI_API_PATH = 'ui-api/nvmeof';
const API_PATH = 'api/nvmeof';
+
configureTestBed({
- providers: [NvmeofService],
+ providers: [
+ NvmeofService,
+ { provide: HostService, useValue: mockHostService },
+ { provide: OrchestratorService, useValue: mockOrchService }
+ ],
imports: [HttpClientTestingModule]
});
]
];
- service.exists('default').subscribe((exists) => {
+ service.exists('default').subscribe((exists: boolean) => {
expect(exists).toBe(true);
});
]
];
- service.exists('non-existent-group').subscribe((exists) => {
+ service.exists('non-existent-group').subscribe((exists: boolean) => {
expect(exists).toBe(false);
});
});
it('should check if gateway group exists - returns false on API error', () => {
- service.exists('test-group').subscribe((exists) => {
+ service.exists('test-group').subscribe((exists: boolean) => {
expect(exists).toBe(false);
});
expect(req.request.method).toBe('GET');
req.error(new ErrorEvent('Network error'));
});
+
+ it('should get available hosts', () => {
+ const mockGroups = [[{ placement: { hosts: ['used-host'] } }]];
+ const mockHosts = [
+ { hostname: 'used-host', status: 'Available' },
+ { hostname: 'free-host', status: 'Available' }
+ ];
+
+ mockOrchService.status.mockReturnValue(of({ available: true }));
+ mockHostService.checkHostsFactsAvailable.mockReturnValue(true);
+ mockHostService.list.mockReturnValue(of(mockHosts));
+
+ service.getAvailableHosts().subscribe((hosts: any[]) => {
+ expect(hosts.length).toBe(1);
+ expect(hosts[0].hostname).toBe('free-host');
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ req.flush(mockGroups);
+ });
+
+ it('should fetch hosts and groups', () => {
+ const mockGroups = [[{ spec: { group: 'group1' } }]];
+ const mockHosts = [{ hostname: 'host1', status: '' }];
+
+ mockHostService.getAllHosts.mockReturnValue(of(mockHosts));
+
+ service.fetchHostsAndGroups().subscribe((result: any) => {
+ expect(result.groups).toEqual(mockGroups);
+ expect(result.hosts[0].status).toBe('Available');
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ req.flush(mockGroups);
+ });
});
describe('test subsystems APIs', () => {
});
it('should call isSubsystemPresent', () => {
spyOn(service, 'getSubsystem').and.returnValue(throwError('test'));
- service.isSubsystemPresent(mockNQN, mockGroupName).subscribe((res) => {
+ service.isSubsystemPresent(mockNQN, mockGroupName).subscribe((res: boolean) => {
expect(res).toBe(false);
});
});
import _ from 'lodash';
import { Observable, forkJoin, of as observableOf } from 'rxjs';
-import { catchError, map, mapTo } from 'rxjs/operators';
+import { catchError, map, mapTo, mergeMap } from 'rxjs/operators';
import { CephServiceSpec } from '../models/service.interface';
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 const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512;
providedIn: 'root'
})
export class NvmeofService {
- constructor(private http: HttpClient, private hostService: HostService) {}
+ constructor(
+ private http: HttpClient,
+ private hostService: HostService,
+ private orchService: OrchestratorService
+ ) {}
- fetchHostsAndGroups() {
+ getAvailableHosts(params: any = {}): Observable<Host[]> {
return forkJoin({
groups: this.listGatewayGroups(),
- hosts: this.hostService.getAllHosts()
+ hosts: this.orchService.status().pipe(
+ mergeMap((orchStatus: OrchestratorStatus) => {
+ const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus);
+ return this.hostService.list(params, factsAvailable.toString()) as Observable<Host[]>;
+ }),
+ map((hosts: Host[]) => {
+ return (hosts || []).map((host: Host) => ({
+ ...host,
+ status: host.status || HostStatus.AVAILABLE
+ }));
+ })
+ )
+ }).pipe(
+ map(({ groups, hosts }) => {
+ const usedHosts = new Set<string>();
+ (groups?.[0] ?? []).forEach((group: CephServiceSpec) => {
+ group.placement?.hosts?.forEach((hostname: string) => usedHosts.add(hostname));
+ });
+ return (hosts || []).filter((host: Host) => {
+ const isAvailable =
+ host.status === HostStatus.AVAILABLE || host.status === HostStatus.RUNNING;
+ return !usedHosts.has(host.hostname) && isAvailable;
+ });
+ })
+ );
+ }
+
+ fetchHostsAndGroups(): Observable<{ groups: CephServiceSpec[][]; hosts: Host[] }> {
+ return forkJoin({
+ groups: this.listGatewayGroups(),
+ hosts: this.hostService.getAllHosts().pipe(
+ map((hosts: Host[]) => {
+ return (hosts || []).map((host: Host) => ({
+ ...host,
+ status: host.status || HostStatus.AVAILABLE
+ }));
+ })
+ )
});
}
placement: CephServicePlacement;
}
+// Type for service spec update payload (excludes read-only status field)
+export type CephServiceSpecUpdate = Omit<CephServiceSpec, 'status'>;
+
export interface CephServiceAdditionalSpec {
backend_service: string;
api_user: string;
'nvmeof/gateway/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.nvmeofGateway(metadata)
),
+ 'nvmeof/gateway/node/add': this.newTaskMessage(this.commonOperations.add, (metadata) =>
+ this.nvmeofGatewayNode(metadata)
+ ),
'nvmeof/subsystem/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nvmeofSubsystem(metadata)
),
nvmeofGateway(metadata: any) {
return $localize`Gateway group '${metadata.group}'`;
}
+
+ nvmeofGatewayNode(metadata: any) {
+ return $localize`hosts to gateway group '${metadata.group_name}'`;
+ }
nvmeofListener(metadata: any) {
return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
}