From: pujashahu Date: Mon, 8 Dec 2025 07:09:17 +0000 (+0530) Subject: mgr/dashboard: NVMeof-Create Gatway group Form X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=refs%2Fpull%2F66546%2Fhead;p=ceph.git mgr/dashboard: NVMeof-Create Gatway group Form Fixes: https://tracker.ceph.com/issues/74134 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts index a0278e60b1ec..9725b2ab6bd4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts @@ -76,6 +76,8 @@ import Reset from '@carbon/icons/es/reset/32'; 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: [ @@ -103,7 +105,8 @@ import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gatew DatePickerModule, ComboBoxModule, TabsModule, - TagModule + TagModule, + GridModule ], declarations: [ RbdListComponent, @@ -140,7 +143,9 @@ import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gatew NvmeofNamespacesListComponent, NvmeofNamespacesFormComponent, NvmeofInitiatorsListComponent, - NvmeofInitiatorsFormComponent + NvmeofInitiatorsFormComponent, + NvmeofGatewayNodeComponent, + NvmeofGroupFormComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] }) @@ -294,7 +299,12 @@ const routes: Routes = [ }, 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, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts index b17aa751ce55..7d6eec88134d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-group/nvmeof-gateway-group.component.ts @@ -2,6 +2,7 @@ import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@a 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'; @@ -21,13 +22,17 @@ import { FinishedTask } from '~/app/shared/models/finished-task'; 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 }) @@ -44,6 +49,7 @@ export class NvmeofGatewayGroupComponent implements OnInit { permission: Permission; tableActions: CdTableAction[]; + nodesAvailable = false; columns: CdTableColumn[] = []; selection: CdTableSelection = new CdTableSelection(); gatewayGroup$: Observable; @@ -61,10 +67,12 @@ export class NvmeofGatewayGroupComponent implements OnInit { 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 { @@ -90,10 +98,11 @@ export class NvmeofGatewayGroupComponent implements OnInit { 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 }; @@ -118,9 +127,16 @@ export class NvmeofGatewayGroupComponent implements OnInit { 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( @@ -139,24 +155,23 @@ export class NvmeofGatewayGroupComponent implements OnInit { }) ); }), - 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 { @@ -206,4 +221,27 @@ export class NvmeofGatewayGroupComponent implements OnInit { } }); } + + private checkNodesAvailability(): void { + forkJoin([this.nvmeofService.listGatewayGroups(), this.hostService.getAllHosts()]).subscribe( + ([groups, hosts]: [GatewayGroup[][], any[]]) => { + const usedHosts = new Set(); + 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; + } + ); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html new file mode 100644 index 000000000000..7dc017449018 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.html @@ -0,0 +1,54 @@ + + + + + + {{ value || '-' }} + + + + +
+ @if (value === HostStatus.AVAILABLE) { + + } + + {{ value | titlecase }} +
+ +
+ + +@if (value && value.length > 0) { + {{ label }} +} @else { + - +} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts new file mode 100644 index 000000000000..da5ad197af65 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.spec.ts @@ -0,0 +1,461 @@ +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; + 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); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts new file mode 100644 index 000000000000..69d61470c553 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway-node/nvmeof-gateway-node.component.ts @@ -0,0 +1,197 @@ +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; + + @ViewChild('statusTpl', { static: true }) + statusTpl: TemplateRef; + + @ViewChild('addrTpl', { static: true }) + addrTpl: TemplateRef; + + @ViewChild('labelsTpl', { static: true }) + labelsTpl: TemplateRef; + + @ViewChild('orchTmpl', { static: true }) + orchTmpl: TemplateRef; + + @Output() selectionChange = new EventEmitter(); + @Output() hostsLoaded = new EventEmitter(); + + usedHostnames: Set = 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(); + + 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, Host[]]) => + this.processHostResults(usedHostnames, hostList), + () => { + this.isLoadingHosts = false; + context.error(); + } + ); + } + + private buildUsedHostsObservable() { + return this.nvmeofService.listGatewayGroups().pipe( + map((groups: CephServiceSpec[][]) => { + const usedHosts = new Set(); + 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, 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); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html new file mode 100644 index 000000000000..9c498ec9da05 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.html @@ -0,0 +1,129 @@ +
+
+
+
+

{{ action | titlecase }} {{ resource }}

+ + + A logical group of gateways that hosts will connect to. + + + +
+
+
+ + Gateway group name + + + This field is required. + Group name must be unique. + Special characters are not allowed. + +
+
+ +
+
+ + + + + + + This field is required. + +
+
+ + +
+
+

Select target nodes

+ + + Gateway nodes to run NVMe-oF target pods/services + + +
+
+ +
+
+ +
+ + +
+
+
+
+ + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.scss new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts new file mode 100644 index 000000000000..f3fae1748a32 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.spec.ts @@ -0,0 +1,224 @@ +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; + 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'); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts new file mode 100644 index 000000000000..f91d156d68bf --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-group-form /nvmeof-group-form.component.ts @@ -0,0 +1,180 @@ +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'); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html index c9956b82e8cc..4a794e11a9f6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html @@ -1,6 +1,6 @@
+ [columnNumbers]="{md: 6}"> { return this.http.get(`${this.baseUIURL}/list`); } + + checkHostsFactsAvailable(orchStatus?: OrchestratorStatus): boolean { + const orchFeatures = orchStatus?.features; + if (!_.isEmpty(orchFeatures)) { + return !!orchFeatures.get_facts?.available; + } + return false; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts index ccecea5f0205..17d6e83548d3 100755 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts @@ -41,6 +41,58 @@ describe('NvmeofService', () => { 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', () => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts index 0fd598e5f53b..7dfc417275d1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts @@ -3,7 +3,7 @@ import { HttpClient } from '@angular/common/http'; 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; @@ -198,4 +198,20 @@ export class NvmeofService { } ); } + + // Check if gateway group exists + exists(groupName: string): Observable { + 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); + }) + ); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts index c487d8cc291e..08cc137916b9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/constants/app.constants.ts @@ -43,7 +43,8 @@ export enum URLVerbs { /* Multi-cluster */ CONNECT = 'connect', - RECONNECT = 'reconnect' + RECONNECT = 'reconnect', + GATEWAY_GROUP = 'Gateway group' } export enum ActionLabels { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/host-status.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/host-status.enum.ts new file mode 100644 index 000000000000..89e26422c97d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/host-status.enum.ts @@ -0,0 +1,5 @@ +export enum HostStatus { + AVAILABLE = 'Available', + MAINTENANCE = 'Maintenance', + RUNNING = 'Running' +}