import SubtractAlt from '@carbon/icons/es/subtract--alt/20';
import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32';
import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component';
+import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component';
+import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component';
@NgModule({
imports: [
DatePickerModule,
ComboBoxModule,
TabsModule,
- TagModule
+ TagModule,
+ GridModule
],
declarations: [
RbdListComponent,
NvmeofNamespacesListComponent,
NvmeofNamespacesFormComponent,
NvmeofInitiatorsListComponent,
- NvmeofInitiatorsFormComponent
+ NvmeofInitiatorsFormComponent,
+ NvmeofGatewayNodeComponent,
+ NvmeofGroupFormComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
},
children: [
{ path: '', redirectTo: 'gateways', pathMatch: 'full' },
- { path: '', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } },
+ { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } },
+ {
+ path: `gateways/${URLVerbs.CREATE}`,
+ component: NvmeofGroupFormComponent,
+ data: { breadcrumbs: `${ActionLabels.CREATE}${URLVerbs.GATEWAY_GROUP}` }
+ },
{
path: 'subsystems',
component: NvmeofSubsystemsComponent,
import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
import { catchError, map, switchMap, tap } from 'rxjs/operators';
import { GatewayGroup, NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { HostService } from '~/app/shared/api/host.service';
import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
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';
+
+const BASE_URL = 'block/nvmeof/gateways';
@Component({
selector: 'cd-nvmeof-gateway-group',
templateUrl: './nvmeof-gateway-group.component.html',
styleUrls: ['./nvmeof-gateway-group.component.scss'],
standalone: false,
- encapsulation: ViewEncapsulation.None
+ encapsulation: ViewEncapsulation.None,
+ providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }]
})
export class NvmeofGatewayGroupComponent implements OnInit {
@ViewChild(TableComponent, { static: true })
permission: Permission;
tableActions: CdTableAction[];
+ nodesAvailable = false;
columns: CdTableColumn[] = [];
selection: CdTableSelection = new CdTableSelection();
gatewayGroup$: Observable<CephServiceSpec[]>;
public actionLabels: ActionLabelsI18n,
private authStorageService: AuthStorageService,
private nvmeofService: NvmeofService,
+ private hostService: HostService,
public modalService: ModalCdsService,
private cephServiceService: CephServiceService,
public taskWrapper: TaskWrapperService,
- private notificationService: NotificationService
+ private notificationService: NotificationService,
+ private urlBuilder: URLBuilderService
) {}
ngOnInit(): void {
cellTemplate: this.dateTpl
}
];
-
const createAction: CdTableAction = {
permission: 'create',
icon: Icons.add,
+ disable: () => (this.nodesAvailable ? false : $localize`Gateway nodes are not available`),
+ routerLink: () => this.urlBuilder.getCreate(),
name: this.actionLabels.CREATE,
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
};
groups.map((group: NvmeofGatewayGroup) => {
const isRunning = (group.status?.running ?? 0) > 0;
const subsystemsObservable = isRunning
- ? this.nvmeofService
- .listSubsystems(group.spec.group)
- .pipe(catchError(() => of([])))
+ ? this.nvmeofService.listSubsystems(group.spec.group).pipe(
+ catchError(() => {
+ this.notificationService.show(
+ NotificationType.error,
+ $localize`Unable to fetch Gateway group`,
+ $localize`Gateway group does not exist`
+ );
+ return of([]);
+ })
+ )
: of([]);
return subsystemsObservable.pipe(
})
);
}),
- catchError((error) => {
+ catchError(() => {
this.notificationService.show(
NotificationType.error,
$localize`Unable to fetch Gateway group`,
$localize`Gateway group does not exist`
);
- if (error?.preventDefault) {
- error.preventDefault();
- }
return of([]);
})
)
)
);
+ this.checkNodesAvailability();
}
fetchData(): void {
this.subject.next([]);
+ this.checkNodesAvailability();
}
updateSelection(selection: CdTableSelection): void {
}
});
}
+
+ private checkNodesAvailability(): void {
+ forkJoin([this.nvmeofService.listGatewayGroups(), this.hostService.getAllHosts()]).subscribe(
+ ([groups, hosts]: [GatewayGroup[][], any[]]) => {
+ const usedHosts = new Set<string>();
+ const groupList = groups?.[0] ?? [];
+ groupList.forEach((group: CephServiceSpec) => {
+ const placementHosts = group.placement?.hosts || [];
+ placementHosts.forEach((hostname: string) => usedHosts.add(hostname));
+ });
+
+ const availableHosts = (hosts || []).filter((host) => {
+ const hostname = host.hostname;
+ return hostname && !usedHosts.has(hostname);
+ });
+
+ this.nodesAvailable = availableHosts.length > 0;
+ },
+ () => {
+ this.nodesAvailable = false;
+ }
+ );
+ }
}
--- /dev/null
+
+<cd-table
+ #table
+ [data]="hosts"
+ [columns]="columns"
+ columnMode="flex"
+ (fetchData)="getHosts($event)"
+ selectionType="multiClick"
+ [searchableObjects]="true"
+ [serverSide]="false"
+ [maxLimit]="25"
+ (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>
+
+<ng-template
+ #addrTpl
+ let-value="data.value"
+>
+ <span>{{ value || '-' }}</span>
+
+</ng-template>
+
+<ng-template
+ #statusTpl
+ let-value="data.value"
+ let-row="data.row"
+>
+ <div
+ [cdsStack]="'horizontal'"
+ gap="4"
+ >
+ @if (value === HostStatus.AVAILABLE) {
+ <cd-icon type="success"></cd-icon>
+ }
+
+ <span class="cds-ml-3">{{ value | titlecase }}</span>
+ </div>
+
+</ng-template>
+
+<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>
+} @else {
+ <span>-</span>
+}
+</ng-template>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of, throwError } from 'rxjs';
+
+import { CephModule } from '~/app/ceph/ceph.module';
+import { CephSharedModule } from '~/app/ceph/shared/ceph-shared.module';
+import { CoreModule } from '~/app/core/core.module';
+import { HostService } from '~/app/shared/api/host.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { HostStatus } from '~/app/shared/enum/host-status.enum';
+import { Permissions } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { TagModule } from 'carbon-components-angular';
+import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node.component';
+
+describe('NvmeofGatewayNodeComponent', () => {
+ let component: NvmeofGatewayNodeComponent;
+ let fixture: ComponentFixture<NvmeofGatewayNodeComponent>;
+ let hostService: HostService;
+ let orchService: OrchestratorService;
+ let nvmeofService: NvmeofService;
+
+ const fakeAuthStorageService = {
+ getPermissions: () => {
+ return new Permissions({ nvmeof: ['read', 'update', 'create', 'delete'] });
+ }
+ };
+
+ const mockGatewayNodes = [
+ {
+ hostname: 'gateway-node-1',
+ addr: '192.168.1.10',
+ status: HostStatus.AVAILABLE,
+ labels: ['nvmeof', 'gateway'],
+ services: [
+ {
+ type: 'nvmeof-gw',
+ id: 'gateway-1'
+ }
+ ],
+ ceph_version: 'ceph version 18.0.0',
+ sources: {
+ ceph: true,
+ orchestrator: true
+ },
+ service_instances: [] as any[]
+ },
+ {
+ hostname: 'gateway-node-2',
+ addr: '192.168.1.11',
+ status: HostStatus.MAINTENANCE,
+ labels: ['nvmeof'],
+ services: [
+ {
+ type: 'nvmeof-gw',
+ id: 'gateway-2'
+ }
+ ],
+ ceph_version: 'ceph version 18.0.0',
+ sources: {
+ ceph: true,
+ orchestrator: true
+ },
+ service_instances: [] as any[]
+ },
+ {
+ hostname: 'gateway-node-3',
+ addr: '192.168.1.12',
+ status: '',
+ labels: [],
+ services: [],
+ ceph_version: 'ceph version 18.0.0',
+ sources: {
+ ceph: true,
+ orchestrator: false
+ },
+ service_instances: [] as any[]
+ }
+ ];
+
+ configureTestBed({
+ imports: [
+ BrowserAnimationsModule,
+ CephSharedModule,
+ SharedModule,
+ HttpClientTestingModule,
+ RouterTestingModule,
+ CephModule,
+ CoreModule,
+ TagModule
+ ],
+ providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeofGatewayNodeComponent);
+ component = fixture.componentInstance;
+ hostService = TestBed.inject(HostService);
+ orchService = TestBed.inject(OrchestratorService);
+ nvmeofService = TestBed.inject(NvmeofService);
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize columns on component init', () => {
+ component.ngOnInit();
+
+ expect(component.columns).toBeDefined();
+ expect(component.columns.length).toBeGreaterThan(0);
+ expect(component.columns[0].name).toBe('Hostname');
+ expect(component.columns[0].prop).toBe('hostname');
+ });
+
+ it('should have all required columns defined', () => {
+ component.ngOnInit();
+
+ const columnProps = component.columns.map((col) => col.prop);
+ expect(columnProps).toContain('hostname');
+ expect(columnProps).toContain('addr');
+ expect(columnProps).toContain('status');
+ expect(columnProps).toContain('labels');
+ });
+
+ it('should initialize with default values', () => {
+ expect(component.hosts).toEqual([]);
+ expect(component.isLoadingHosts).toBe(false);
+ expect(component.count).toBe(5);
+ expect(component.permission).toBeDefined();
+ });
+
+ it('should update selection', () => {
+ const selection = new CdTableSelection();
+ selection.selected = [mockGatewayNodes[0]];
+
+ component.updateSelection(selection);
+
+ expect(component.selection).toBe(selection);
+ expect(component.selection.selected.length).toBe(1);
+ });
+
+ it('should get selected hosts', () => {
+ component.selection = new CdTableSelection();
+ component.selection.selected = [mockGatewayNodes[0], mockGatewayNodes[1]];
+
+ // ensure hosts list contains the selected hosts for lookup
+ component.hosts = [mockGatewayNodes[0], mockGatewayNodes[1]];
+
+ const selectedHosts = component
+ .getSelectedHostnames()
+ .map((hostname) => component.hosts.find((host) => host.hostname === hostname));
+
+ expect(selectedHosts.length).toBe(2);
+ expect(selectedHosts[0]).toEqual(mockGatewayNodes[0]);
+ expect(selectedHosts[1]).toEqual(mockGatewayNodes[1]);
+ });
+
+ it('should get selected hostnames', () => {
+ component.selection = new CdTableSelection();
+ component.selection.selected = [mockGatewayNodes[0], mockGatewayNodes[1]];
+
+ const selectedHostnames = component.getSelectedHostnames();
+
+ expect(selectedHostnames).toEqual(['gateway-node-1', 'gateway-node-2']);
+ });
+
+ it('should load hosts with orchestrator available and facts feature enabled', (done) => {
+ 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([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ expect(hostListSpy).toHaveBeenCalled();
+ // Only hosts with status 'available', '' or 'running' are included (excluding 'maintenance')
+ expect(component.hosts.length).toBe(2);
+ expect(component.isLoadingHosts).toBe(false);
+ expect(component.hosts[0]['hostname']).toBe('gateway-node-1');
+ expect(component.hosts[0]['status']).toBe(HostStatus.AVAILABLE);
+ done();
+ }, 100);
+ });
+
+ it('should normalize empty status to "available"', (done) => {
+ 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([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ // Host at index 1 in filtered list (gateway-node-3 has empty status which becomes 'available')
+ const nodeWithEmptyStatus = component.hosts.find((h) => h.hostname === 'gateway-node-3');
+ expect(nodeWithEmptyStatus?.['status']).toBe(HostStatus.AVAILABLE);
+ done();
+ }, 100);
+ });
+
+ it('should set count to hosts length', (done) => {
+ 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([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ // Count should equal the filtered hosts length
+ expect(component.count).toBe(component.hosts.length);
+ done();
+ }, 100);
+ });
+
+ it('should set count to 0 when no hosts are returned', (done) => {
+ 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([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ expect(component.count).toBe(0);
+ expect(component.hosts.length).toBe(0);
+ done();
+ }, 100);
+ });
+
+ it('should handle error when fetching hosts', (done) => {
+ 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([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ const context = new CdTableFetchDataContext(() => undefined);
+ spyOn(context, 'error');
+
+ component.getHosts(context);
+
+ setTimeout(() => {
+ expect(component.isLoadingHosts).toBe(false);
+ expect(context.error).toHaveBeenCalled();
+ done();
+ }, 100);
+ });
+
+ it('should check hosts facts available when orchestrator features present', () => {
+ component.orchStatus = {
+ available: true,
+ features: new Map([['get_facts', { available: true }]])
+ } as any;
+
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+
+ const result = component.checkHostsFactsAvailable();
+
+ expect(result).toBe(true);
+ });
+
+ it('should return false when get_facts feature is not available', () => {
+ component.orchStatus = {
+ available: true,
+ features: new Map([['other_feature', { available: true }]])
+ } as any;
+
+ const result = component.checkHostsFactsAvailable();
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when orchestrator status features are empty', () => {
+ component.orchStatus = {
+ available: true,
+ features: new Map()
+ } as any;
+
+ const result = component.checkHostsFactsAvailable();
+
+ expect(result).toBe(false);
+ });
+
+ it('should return false when orchestrator status is undefined', () => {
+ component.orchStatus = undefined;
+
+ const result = component.checkHostsFactsAvailable();
+
+ expect(result).toBe(false);
+ });
+
+ it('should not re-fetch if already loading', (done) => {
+ component.isLoadingHosts = true;
+ const hostListSpy = spyOn(hostService, 'list');
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ expect(hostListSpy).not.toHaveBeenCalled();
+ done();
+ }, 100);
+ });
+
+ it('should unsubscribe on component destroy', () => {
+ const destroy$ = component['destroy$'];
+ spyOn(destroy$, 'next');
+ spyOn(destroy$, 'complete');
+
+ component.ngOnDestroy();
+
+ expect(destroy$.next).toHaveBeenCalled();
+ expect(destroy$.complete).toHaveBeenCalled();
+ });
+
+ it('should handle host list with various label types', (done) => {
+ const hostsWithLabels = [
+ {
+ ...mockGatewayNodes[0],
+ labels: ['nvmeof', 'gateway', 'high-priority']
+ },
+ {
+ ...mockGatewayNodes[2],
+ labels: []
+ }
+ ];
+
+ spyOn(hostService, 'list').and.returnValue(of(hostsWithLabels));
+ const mockOrcStatus: any = {
+ available: true,
+ features: new Map()
+ };
+
+ spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ expect(component.hosts[0]['labels'].length).toBe(3);
+ expect(component.hosts[1]['labels'].length).toBe(0);
+ done();
+ }, 100);
+ });
+
+ it('should handle hosts with multiple services', (done) => {
+ const hostsWithServices = [
+ {
+ ...mockGatewayNodes[0],
+ services: [
+ { type: 'nvmeof-gw', id: 'gateway-1' },
+ { type: 'mon', id: '0' }
+ ]
+ }
+ ];
+
+ spyOn(hostService, 'list').and.returnValue(of(hostsWithServices));
+ const mockOrcStatus: any = {
+ available: true,
+ features: new Map()
+ };
+
+ spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ expect(component.hosts[0]['services'].length).toBe(2);
+ done();
+ }, 100);
+ });
+
+ it('should initialize table context on first getHosts call', (done) => {
+ 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([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ expect((component as any).tableContext).toBeNull();
+
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+
+ setTimeout(() => {
+ expect((component as any).tableContext).not.toBeNull();
+ done();
+ }, 100);
+ });
+
+ it('should reuse table context if already set', (done) => {
+ const context = new CdTableFetchDataContext(() => undefined);
+ 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([[]]));
+ spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ fixture.detectChanges();
+
+ component.getHosts(context);
+
+ setTimeout(() => {
+ const storedContext = (component as any).tableContext;
+ expect(storedContext).toBe(context);
+ done();
+ }, 100);
+ });
+});
--- /dev/null
+import {
+ Component,
+ EventEmitter,
+ OnDestroy,
+ OnInit,
+ Output,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+import { forkJoin, Subject } from 'rxjs';
+import { map, mergeMap, takeUntil } 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 { 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 { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
+import { Permission } from '~/app/shared/models/permissions';
+
+import { Host } from '~/app/shared/models/host.interface';
+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 _ from 'lodash';
+
+@Component({
+ selector: 'cd-nvmeof-gateway-node',
+ templateUrl: './nvmeof-gateway-node.component.html',
+ styleUrls: ['./nvmeof-gateway-node.component.scss'],
+ standalone: false
+})
+export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
+ @ViewChild(TableComponent, { static: true })
+ table: TableComponent;
+
+ @ViewChild('hostNameTpl', { static: true })
+ hostNameTpl: TemplateRef<any>;
+
+ @ViewChild('statusTpl', { static: true })
+ statusTpl: TemplateRef<any>;
+
+ @ViewChild('addrTpl', { static: true })
+ addrTpl: TemplateRef<any>;
+
+ @ViewChild('labelsTpl', { static: true })
+ labelsTpl: TemplateRef<any>;
+
+ @ViewChild('orchTmpl', { static: true })
+ orchTmpl: TemplateRef<any>;
+
+ @Output() selectionChange = new EventEmitter<CdTableSelection>();
+ @Output() hostsLoaded = new EventEmitter<number>();
+
+ usedHostnames: Set<string> = new Set();
+
+ permission: Permission;
+ columns: CdTableColumn[] = [];
+ hosts: Host[] = [];
+ isLoadingHosts = false;
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ icons = Icons;
+ HostStatus = HostStatus;
+ private tableContext: CdTableFetchDataContext = null;
+ count = 5;
+ orchStatus: OrchestratorStatus;
+ private destroy$ = new Subject<void>();
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private hostService: HostService,
+ private orchService: OrchestratorService,
+ private nvmeofService: NvmeofService
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ }
+
+ ngOnInit(): void {
+ this.columns = [
+ {
+ name: $localize`Hostname`,
+ prop: 'hostname',
+ flexGrow: 1,
+ cellTemplate: this.hostNameTpl
+ },
+ {
+ name: $localize`IP address`,
+ prop: 'addr',
+ flexGrow: 0.8,
+ cellTemplate: this.addrTpl
+ },
+ {
+ name: $localize`Status`,
+ prop: 'status',
+ flexGrow: 0.8,
+ cellTemplate: this.statusTpl
+ },
+ {
+ name: $localize`Labels`,
+ prop: 'labels',
+ flexGrow: 1,
+ cellTemplate: this.labelsTpl
+ }
+ ];
+ }
+
+ ngOnDestroy(): void {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+
+ updateSelection(selection: CdTableSelection): void {
+ this.selection = selection;
+ this.selectionChange.emit(selection);
+ }
+
+ getSelectedHostnames(): string[] {
+ return this.selection.selected.map((host: Host) => host.hostname);
+ }
+
+ getHosts(context: CdTableFetchDataContext): void {
+ if (context !== null) {
+ this.tableContext = context;
+ }
+ if (this.tableContext == null) {
+ this.tableContext = new CdTableFetchDataContext(() => undefined);
+ }
+ if (this.isLoadingHosts) {
+ return;
+ }
+ this.isLoadingHosts = true;
+
+ forkJoin([this.buildUsedHostsObservable(), this.buildHostListObservable()])
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(
+ ([usedHostnames, hostList]: [Set<string>, Host[]]) =>
+ this.processHostResults(usedHostnames, hostList),
+ () => {
+ this.isLoadingHosts = false;
+ context.error();
+ }
+ );
+ }
+
+ private buildUsedHostsObservable() {
+ return this.nvmeofService.listGatewayGroups().pipe(
+ map((groups: CephServiceSpec[][]) => {
+ const usedHosts = new Set<string>();
+ const groupList = groups?.[0] ?? [];
+ groupList.forEach((group: CephServiceSpec) => {
+ const hosts = group.placement?.hosts || [];
+ hosts.forEach((hostname: string) => usedHosts.add(hostname));
+ });
+ return usedHosts;
+ })
+ );
+ }
+
+ private buildHostListObservable() {
+ return this.orchService.status().pipe(
+ mergeMap((orchStatus) => {
+ this.orchStatus = orchStatus;
+ const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus);
+ return this.hostService.list(this.tableContext?.toParams(), factsAvailable.toString());
+ })
+ );
+ }
+
+ private processHostResults(usedHostnames: Set<string>, hostList: Host[]) {
+ this.usedHostnames = usedHostnames;
+ this.hosts = (hostList || [])
+ .map((host: Host) => ({
+ ...host,
+ status: host.status || HostStatus.AVAILABLE
+ }))
+ .filter((host: Host) => {
+ const isNotUsed = !this.usedHostnames.has(host.hostname);
+ const status = host.status || HostStatus.AVAILABLE;
+ const isAvailable = status === HostStatus.AVAILABLE || status === HostStatus.RUNNING;
+ return isNotUsed && isAvailable;
+ });
+
+ this.isLoadingHosts = false;
+ this.count = this.hosts.length;
+ this.hostsLoaded.emit(this.count);
+ }
+
+ checkHostsFactsAvailable(): boolean {
+ return this.hostService.checkHostsFactsAvailable(this.orchStatus);
+ }
+}
--- /dev/null
+<form
+ #formDir="ngForm"
+ [formGroup]="groupForm"
+ novalidate
+>
+ <div cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+ <div cdsCol
+ [columnNumbers]="{sm: 4, md: 8}">
+ <div cdsRow
+ class="form-heading form-item">
+ <h3>{{ action | titlecase }} {{ resource }}</h3>
+ <cd-help-text>
+ <span i18n>
+ A logical group of gateways that hosts will connect to.
+ </span>
+ </cd-help-text>
+ <cd-help-text [formAllFieldsRequired]="true"></cd-help-text>
+ </div>
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cds-text-label
+ labelInputID="name"
+ i18n
+ i18n-helperText
+ helperText="A unique name to identify this gateway group."
+ cdRequiredField="Gateway group name"
+ [invalid]="groupName.isInvalid">
+ Gateway group name
+ <input
+ cdsText
+ cdValidate
+ type="text"
+ id="groupName"
+ #groupName="cdValidate"
+ autofocus
+ formControlName="groupName"
+ [invalid]="groupName.isInvalid"
+ />
+ </cds-text-label>
+ <span
+ class="invalid-feedback"
+ *ngIf="groupForm.showError('groupName', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span
+ class="invalid-feedback"
+ *ngIf="groupForm.get('groupName')?.hasError('notUnique') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+ i18n>Group name must be unique.</span>
+ <span
+ class="invalid-feedback"
+ *ngIf="groupForm.get('groupName')?.hasError('invalidChars') && (groupForm.get('groupName')?.dirty || groupForm.get('groupName')?.touched)"
+ i18n>Special characters are not allowed.</span>
+
+ </div>
+ </div>
+ <!-- Pool -->
+ <div cdsRow
+ class="form-item">
+ <div cdsCol>
+ <cds-select
+ label="Block pool"
+ helperText="An RBD application-enabled pool in which the gateway configuration can be managed."
+ labelInputID="pool"
+ id="pool"
+ formControlName="pool"
+ cdRequiredField="Block pool"
+ [invalid]="groupForm.controls.pool.invalid && groupForm.controls.pool.dirty"
+ [invalidText]="poolError"
+ i18n-label
+ i18n-helperText
+ >
+ <option *ngIf="poolsLoading"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="!poolsLoading && pools.length === 0"
+ [ngValue]="null"
+ i18n>-- No block pools available --</option>
+ <option *ngFor="let pool of pools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </cds-select>
+ <ng-template #poolError>
+ <span class="invalid-feedback"
+ *ngIf="groupForm.showError('pool', formDir, 'required')"
+ i18n>This field is required.</span>
+ </ng-template>
+ </div>
+ </div>
+
+ <!-- Target Nodes Selection -->
+ <div
+ cdsRow
+ class="form-item"
+ >
+ <div cdsCol>
+ <h1 class="cds--type-heading-02">Select target nodes</h1>
+ <cd-help-text>
+ <span i18n>
+ Gateway nodes to run NVMe-oF target pods/services
+ </span>
+ </cd-help-text>
+ </div>
+ <div
+ cdsCol
+ class="cds-pt-3 cds-pb-3"
+ >
+ <cd-nvmeof-gateway-node
+ (hostsLoaded)="onHostsLoaded($event)"
+ ></cd-nvmeof-gateway-node>
+ </div>
+ </div>
+
+ <div cdsRow>
+ <cd-form-button-panel
+ (submitActionEvent)="onSubmit()"
+ [form]="groupForm"
+ [submitText]="(action | titlecase) + ' ' + (resource)"
+ [disabled]="isCreateDisabled"
+ wrappingClass="text-right form-button"
+ >
+ </cd-form-button-panel>
+ </div>
+ </div>
+ </div>
+</form>
+
+
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { Router } from '@angular/router';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+
+import { ToastrModule } from 'ngx-toastr';
+import { of } from 'rxjs';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+
+import { NvmeofGroupFormComponent } from './nvmeof-group-form.component';
+import { GridModule, InputModule, SelectModule } from 'carbon-components-angular';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FormHelper } from '~/testing/unit-test-helper';
+
+describe('NvmeofGroupFormComponent', () => {
+ let component: NvmeofGroupFormComponent;
+ let fixture: ComponentFixture<NvmeofGroupFormComponent>;
+ let form: CdFormGroup;
+ let formHelper: FormHelper;
+ let poolService: PoolService;
+ let taskWrapperService: TaskWrapperService;
+ let cephServiceService: CephServiceService;
+ let router: Router;
+
+ const mockPools = [
+ { pool_name: 'rbd', application_metadata: ['rbd'] },
+ { pool_name: 'rbd', application_metadata: ['rbd'] },
+ { pool_name: 'pool2', application_metadata: ['rgw'] }
+ ];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofGroupFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ GridModule,
+ InputModule,
+ SelectModule,
+ ToastrModule.forRoot()
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofGroupFormComponent);
+ component = fixture.componentInstance;
+ poolService = TestBed.inject(PoolService);
+ taskWrapperService = TestBed.inject(TaskWrapperService);
+ cephServiceService = TestBed.inject(CephServiceService);
+ router = TestBed.inject(Router);
+
+ spyOn(poolService, 'list').and.returnValue(Promise.resolve(mockPools));
+
+ component.ngOnInit();
+ form = component.groupForm;
+ formHelper = new FormHelper(form);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize form with empty fields', () => {
+ expect(form.controls.groupName.value).toBeNull();
+ expect(form.controls.unmanaged.value).toBe(false);
+ });
+
+ it('should set action to CREATE on init', () => {
+ expect(component.action).toBe('Create');
+ });
+
+ it('should set resource to gateway group', () => {
+ expect(component.resource).toBe('gateway group');
+ });
+
+ describe('form validation', () => {
+ it('should require groupName', () => {
+ formHelper.setValue('groupName', '');
+ formHelper.expectError('groupName', 'required');
+ });
+
+ it('should require pool', () => {
+ formHelper.setValue('pool', null);
+ formHelper.expectError('pool', 'required');
+ });
+
+ it('should be valid when groupName and pool are set', () => {
+ formHelper.setValue('groupName', 'test-group');
+ formHelper.setValue('pool', 'rbd');
+ expect(form.valid).toBe(true);
+ });
+ });
+
+ describe('loadPools', () => {
+ it('should load pools and filter by rbd application metadata', fakeAsync(() => {
+ component.loadPools();
+ tick();
+ expect(component.pools.length).toBe(2);
+ expect(component.pools.map((p) => p.pool_name)).toEqual(['rbd', 'rbd']);
+ }));
+
+ it('should set default pool to rbd if available', fakeAsync(() => {
+ component.groupForm.get('pool').setValue(null);
+ component.loadPools();
+ tick();
+ expect(component.groupForm.get('pool').value).toBe('rbd');
+ }));
+
+ it('should set first pool if rbd is not available', fakeAsync(() => {
+ component.groupForm.get('pool').setValue(null);
+ const poolsWithoutRbd = [{ pool_name: 'custom-pool', application_metadata: ['rbd'] }];
+ (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve(poolsWithoutRbd));
+ component.loadPools();
+ tick();
+ expect(component.groupForm.get('pool').value).toBe('custom-pool');
+ }));
+
+ it('should handle empty pools', fakeAsync(() => {
+ (poolService.list as jasmine.Spy).and.returnValue(Promise.resolve([]));
+ component.loadPools();
+ tick();
+ expect(component.pools.length).toBe(0);
+ expect(component.poolsLoading).toBe(false);
+ }));
+
+ it('should handle pool loading error', fakeAsync(() => {
+ (poolService.list as jasmine.Spy).and.returnValue(Promise.reject('error'));
+ component.loadPools();
+ tick();
+ expect(component.pools).toEqual([]);
+ expect(component.poolsLoading).toBe(false);
+ }));
+ });
+
+ describe('onSubmit', () => {
+ beforeEach(() => {
+ spyOn(cephServiceService, 'create').and.returnValue(of({}));
+ spyOn(taskWrapperService, 'wrapTaskAroundCall').and.callFake(({ call }) => call);
+ spyOn(router, 'navigateByUrl');
+ });
+
+ it('should not call create if no hosts are selected', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [],
+ getSelectedHostnames: (): string[] => []
+ } as any;
+
+ component.groupForm.get('groupName').setValue('test-group');
+ component.groupForm.get('pool').setValue('rbd');
+ component.onSubmit();
+
+ expect(cephServiceService.create).not.toHaveBeenCalled();
+ });
+
+ it('should create service with correct spec', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [{ hostname: 'host1' }, { hostname: 'host2' }],
+ getSelectedHostnames: (): string[] => ['host1', 'host2']
+ } as any;
+
+ component.groupForm.get('groupName').setValue('defalut');
+ component.groupForm.get('pool').setValue('rbd');
+ component.groupForm.get('unmanaged').setValue(false);
+ component.onSubmit();
+
+ expect(cephServiceService.create).toHaveBeenCalledWith({
+ service_type: 'nvmeof',
+ service_id: 'rbd.defalut',
+ pool: 'rbd',
+ group: 'defalut',
+ placement: {
+ hosts: ['host1', 'host2']
+ },
+ unmanaged: false
+ });
+ });
+
+ it('should create service with unmanaged flag set to true', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+ getSelectedHostnames: (): string[] => ['host1']
+ } as any;
+
+ component.groupForm.get('groupName').setValue('unmanaged-group');
+ component.groupForm.get('pool').setValue('rbd');
+ component.groupForm.get('unmanaged').setValue(true);
+ component.onSubmit();
+
+ expect(cephServiceService.create).toHaveBeenCalledWith(
+ jasmine.objectContaining({
+ unmanaged: true,
+ group: 'unmanaged-group',
+ pool: 'rbd'
+ })
+ );
+ });
+
+ it('should navigate to list view on success', () => {
+ component.gatewayNodeComponent = {
+ getSelectedHosts: (): any[] => [{ hostname: 'host1' }],
+ getSelectedHostnames: (): string[] => ['host1']
+ } as any;
+
+ component.groupForm.get('groupName').setValue('test-group');
+ component.groupForm.get('pool').setValue('rbd');
+ component.onSubmit();
+
+ expect(router.navigateByUrl).toHaveBeenCalledWith('/block/nvmeof/gateways');
+ });
+ });
+});
--- /dev/null
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { Pool } from '../../pool/pool';
+import { NvmeofGatewayNodeComponent } from '../nvmeof-gateway-node/nvmeof-gateway-node.component';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CephServiceService } from '~/app/shared/api/ceph-service.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { Router } from '@angular/router';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+@Component({
+ selector: 'cd-nvmeof-group-form',
+ templateUrl: './nvmeof-group-form.component.html',
+ styleUrls: ['./nvmeof-group-form.component.scss'],
+ standalone: false
+})
+export class NvmeofGroupFormComponent extends CdForm implements OnInit {
+ @ViewChild(NvmeofGatewayNodeComponent) gatewayNodeComponent: NvmeofGatewayNodeComponent;
+
+ permission: Permission;
+ groupForm: CdFormGroup;
+ action: string;
+ resource: string;
+ group: string;
+ pools: Pool[] = [];
+ poolsLoading = false;
+ pageURL: string;
+ hasAvailableNodes = true;
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ public actionLabels: ActionLabelsI18n,
+ private poolService: PoolService,
+ private taskWrapperService: TaskWrapperService,
+ private cephServiceService: CephServiceService,
+ private nvmeofService: NvmeofService,
+ private router: Router
+ ) {
+ super();
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ this.resource = $localize`gateway group`;
+ }
+
+ ngOnInit() {
+ this.action = this.actionLabels.CREATE;
+ this.createForm();
+ this.loadPools();
+ }
+
+ createForm() {
+ this.groupForm = new CdFormGroup({
+ groupName: new UntypedFormControl(
+ null,
+ [
+ Validators.required,
+ (control) => {
+ const value = control.value;
+ return value && /[^a-zA-Z0-9_-]/.test(value) ? { invalidChars: true } : null;
+ }
+ ],
+ [CdValidators.unique(this.nvmeofService.exists, this.nvmeofService)]
+ ),
+ pool: new UntypedFormControl('rbd', {
+ validators: [Validators.required]
+ }),
+ unmanaged: new UntypedFormControl(false)
+ });
+ }
+
+ onHostsLoaded(count: number): void {
+ this.hasAvailableNodes = count > 0;
+ }
+
+ get isCreateDisabled(): boolean {
+ if (!this.hasAvailableNodes) {
+ return true;
+ }
+ if (!this.groupForm) {
+ return true;
+ }
+ if (this.groupForm.pending) {
+ return true;
+ }
+ if (this.groupForm.invalid) {
+ return true;
+ }
+ const errors = this.groupForm.errors as { [key: string]: any } | null;
+ if (errors && errors.cdSubmitButton) {
+ return true;
+ }
+ if (this.gatewayNodeComponent) {
+ const selected = this.gatewayNodeComponent.getSelectedHostnames?.() || [];
+ if (selected.length === 0) {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ loadPools() {
+ this.poolsLoading = true;
+ this.poolService.list().then(
+ (pools: Pool[]) => {
+ this.pools = (pools || []).filter(
+ (pool: Pool) => pool.application_metadata && pool.application_metadata.includes('rbd')
+ );
+ this.poolsLoading = false;
+ if (this.pools.length >= 1) {
+ const allPoolNames = this.pools.map((pool) => pool.pool_name);
+ const poolName = allPoolNames.includes('rbd') ? 'rbd' : this.pools[0].pool_name;
+ this.groupForm.patchValue({ pool: poolName });
+ }
+ },
+ () => {
+ this.pools = [];
+ this.poolsLoading = false;
+ }
+ );
+ }
+
+ onSubmit() {
+ if (this.groupForm.invalid) {
+ return;
+ }
+
+ if (this.groupForm.pending) {
+ this.groupForm.setErrors({ cdSubmitButton: true });
+ return;
+ }
+
+ const formValues = this.groupForm.value;
+ const selectedHostnames = this.gatewayNodeComponent?.getSelectedHostnames() || [];
+ if (selectedHostnames.length === 0) {
+ this.groupForm.setErrors({ cdSubmitButton: true });
+ return;
+ }
+ let taskUrl = `service/${URLVerbs.CREATE}`;
+ const serviceName = `${formValues.pool}.${formValues.groupName}`;
+
+ const serviceSpec = {
+ service_type: 'nvmeof',
+ service_id: serviceName,
+ pool: formValues.pool,
+ group: formValues.groupName,
+ placement: {
+ hosts: selectedHostnames
+ },
+ unmanaged: formValues.unmanaged
+ };
+
+ this.taskWrapperService
+ .wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ service_name: `nvmeof.${serviceName}`
+ }),
+ call: this.cephServiceService.create(serviceSpec)
+ })
+ .subscribe({
+ complete: () => {
+ this.goToListView();
+ },
+ error: () => {
+ this.groupForm.setErrors({ cdSubmitButton: true });
+ }
+ });
+ }
+
+ private goToListView() {
+ this.router.navigateByUrl('/block/nvmeof/gateways');
+ }
+}
<div class="pb-3"
cdsCol
- [columnNumbers]="{md: 4}">
+ [columnNumbers]="{md: 6}">
<cds-combo-box
type="single"
label="Selected Gateway Group"
import { SmartDataResponseV1 } from '../models/smart';
import { DeviceService } from '../services/device.service';
import { Host } from '../models/host.interface';
+import { OrchestratorStatus } from '~/app/shared/models/orchestrator.interface';
@Injectable({
providedIn: 'root'
getAllHosts(): Observable<Host[]> {
return this.http.get<Host[]>(`${this.baseUIURL}/list`);
}
+
+ checkHostsFactsAvailable(orchStatus?: OrchestratorStatus): boolean {
+ const orchFeatures = orchStatus?.features;
+ if (!_.isEmpty(orchFeatures)) {
+ return !!orchFeatures.get_facts?.available;
+ }
+ return false;
+ }
}
const req = httpTesting.expectOne(`${API_PATH}/gateway`);
expect(req.request.method).toBe('GET');
});
+
+ it('should check if gateway group exists - returns true when group exists', () => {
+ const mockGroups = [
+ [
+ {
+ spec: { group: 'default' },
+ service_name: 'nvmeof.rbd.default'
+ },
+ {
+ spec: { group: 'test-group' },
+ service_name: 'nvmeof.rbd.test-group'
+ }
+ ]
+ ];
+
+ service.exists('default').subscribe((exists) => {
+ expect(exists).toBe(true);
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ expect(req.request.method).toBe('GET');
+ req.flush(mockGroups);
+ });
+
+ it('should check if gateway group exists - returns false when group does not exist', () => {
+ const mockGroups = [
+ [
+ {
+ spec: { group: 'default' },
+ service_name: 'nvmeof.rbd.default'
+ }
+ ]
+ ];
+
+ service.exists('non-existent-group').subscribe((exists) => {
+ expect(exists).toBe(false);
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ expect(req.request.method).toBe('GET');
+ req.flush(mockGroups);
+ });
+
+ it('should check if gateway group exists - returns false on API error', () => {
+ service.exists('test-group').subscribe((exists) => {
+ expect(exists).toBe(false);
+ });
+
+ const req = httpTesting.expectOne(`${API_PATH}/gateway/group`);
+ expect(req.request.method).toBe('GET');
+ req.error(new ErrorEvent('Network error'));
+ });
});
describe('test subsystems APIs', () => {
import _ from 'lodash';
import { Observable, of as observableOf } from 'rxjs';
-import { catchError, mapTo } from 'rxjs/operators';
+import { catchError, map, mapTo } from 'rxjs/operators';
import { CephServiceSpec } from '../models/service.interface';
export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512;
}
);
}
+
+ // Check if gateway group exists
+ exists(groupName: string): Observable<boolean> {
+ return this.listGatewayGroups().pipe(
+ map((groups: CephServiceSpec[][]) => {
+ const groupsList = groups?.[0] ?? [];
+ return groupsList.some((group: CephServiceSpec) => group?.spec?.group === groupName);
+ }),
+ catchError((error: any) => {
+ if (_.isFunction(error?.preventDefault)) {
+ error.preventDefault();
+ }
+ return observableOf(false);
+ })
+ );
+ }
}
/* Multi-cluster */
CONNECT = 'connect',
- RECONNECT = 'reconnect'
+ RECONNECT = 'reconnect',
+ GATEWAY_GROUP = 'Gateway group'
}
export enum ActionLabels {
--- /dev/null
+export enum HostStatus {
+ AVAILABLE = 'Available',
+ MAINTENANCE = 'Maintenance',
+ RUNNING = 'Running'
+}