SelectModule,
UIShellModule,
TreeviewModule,
+ SideNavModule,
TabsModule,
TagModule,
LayoutModule,
ContainedListModule,
- LayerModule
+ LayerModule,
+ ThemeModule
} from 'carbon-components-angular';
// Icons
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 Search from '@carbon/icons/es/search/32';
+import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component';
+import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component';
+import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
+import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
@NgModule({
imports: [
TreeviewModule,
UIShellModule,
InputModule,
- GridModule,
ButtonModule,
+ GridModule,
IconModule,
CheckboxModule,
RadioModule,
GridModule,
LayerModule,
LayoutModule,
- ContainedListModule
+ ContainedListModule,
+ SideNavModule,
+ ThemeModule
],
declarations: [
RbdListComponent,
NvmeofGroupFormComponent,
NvmeofSubsystemsStepOneComponent,
NvmeofSubsystemsStepTwoComponent,
- NvmeofSubsystemsStepThreeComponent
+ NvmeofSubsystemsStepThreeComponent,
+ NvmeGatewayViewComponent,
+ NvmeofGatewaySubsystemComponent
],
+
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
export class BlockModule {
SubtractFilled,
Reset,
ProgressBarRound,
- SubtractAlt
+ SubtractAlt,
+ Search
]);
}
}
component: NvmeofGroupFormComponent,
data: { breadcrumbs: `${ActionLabels.CREATE}${URLVerbs.GATEWAY_GROUP}` }
},
+
+ {
+ path: `gateways/${URLVerbs.VIEW}/:group`,
+ component: NvmeGatewayViewComponent,
+ data: { breadcrumbs: NvmeGatewayViewBreadcrumbResolver }, // Use resolver here
+ children: [
+ { path: '', redirectTo: 'nodes', pathMatch: 'full' },
+ {
+ path: 'nodes',
+ component: NvmeofGatewayNodeComponent,
+ data: { breadcrumbs: $localize`Gateway nodes`, mode: NvmeofGatewayNodeMode.DETAILS }
+ },
+ {
+ path: 'subsystems',
+ component: NvmeofGatewaySubsystemComponent,
+ data: { breadcrumbs: $localize`Subsystems` }
+ }
+ ]
+ },
{
path: 'subsystems',
component: NvmeofSubsystemsComponent,
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+import { ActivatedRouteSnapshot } from '@angular/router';
+
+import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view-breadcrumb.resolver';
+
+describe('NvmeGatewayViewBreadcrumbResolver', () => {
+ let resolver: NvmeGatewayViewBreadcrumbResolver;
+ let route: ActivatedRouteSnapshot;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [NvmeGatewayViewBreadcrumbResolver]
+ });
+ resolver = TestBed.inject(NvmeGatewayViewBreadcrumbResolver);
+ route = new ActivatedRouteSnapshot();
+ });
+
+ it('should be created', () => {
+ expect(resolver).toBeTruthy();
+ });
+
+ it('should resolve breadcrumb with group name from parent params', () => {
+ route.params = {};
+ Object.defineProperty(route, 'parent', {
+ value: { params: { group: 'test-group' } },
+ writable: true
+ });
+
+ spyOn(resolver, 'getFullPath').and.returnValue('full/path/test-group');
+
+ const result = resolver.resolve(route);
+
+ expect(result).toEqual([{ text: 'test-group', path: 'full/path/test-group' }]);
+ });
+
+ it('should resolve breadcrumb with group name from current params', () => {
+ route.params = { group: 'test-group' };
+ Object.defineProperty(route, 'parent', {
+ value: { params: {} },
+ writable: true
+ });
+ spyOn(resolver, 'getFullPath').and.returnValue('full/path/test-group');
+
+ const result = resolver.resolve(route);
+
+ expect(result).toEqual([{ text: 'test-group', path: 'full/path/test-group' }]);
+ });
+});
--- /dev/null
+import { Injectable } from '@angular/core';
+import { ActivatedRouteSnapshot } from '@angular/router';
+
+import { BreadcrumbsResolver, IBreadcrumb } from '~/app/shared/models/breadcrumbs';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class NvmeGatewayViewBreadcrumbResolver extends BreadcrumbsResolver {
+ resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
+ const group = route.parent?.params?.group || route.params?.group;
+ return [{ text: group, path: this.getFullPath(route) }];
+ }
+}
--- /dev/null
+<cd-sidebar-layout
+ [title]="groupName"
+ [items]="sidebarItems"
+></cd-sidebar-layout>
--- /dev/null
+.breadcrumbs--padding {
+ padding-left: 0 !important;
+}
+
+.cds--breadcrumb {
+ margin-top: 0;
+ padding: var(--cds-spacing-05);
+}
--- /dev/null
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+
+import { RouterTestingModule } from '@angular/router/testing';
+import { NvmeGatewayViewComponent } from './nvme-gateway-view.component';
+
+describe('NvmeGatewayViewComponent', () => {
+ let component: NvmeGatewayViewComponent;
+ let fixture: ComponentFixture<NvmeGatewayViewComponent>;
+
+ beforeEach(
+ waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [NvmeGatewayViewComponent],
+ imports: [RouterTestingModule, SideNavModule, ThemeModule],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ }).compileComponents();
+ })
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeGatewayViewComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, OnInit, ViewEncapsulation } from '@angular/core';
+import { ActivatedRoute, ParamMap } from '@angular/router';
+import { Observable, of } from 'rxjs';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component';
+
+@Component({
+ selector: 'cd-nvme-gateway-view',
+ templateUrl: './nvme-gateway-view.component.html',
+ styleUrls: ['./nvme-gateway-view.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ standalone: false
+})
+export class NvmeGatewayViewComponent implements OnInit {
+ groupName: string;
+ subsystems$: Observable<NvmeofSubsystem[]> = of([]);
+ public readonly basePath = '/block/nvmeof/gateways/view';
+ sidebarItems: SidebarItem[] = [];
+
+ constructor(private route: ActivatedRoute) {}
+
+ ngOnInit() {
+ this.route.paramMap.subscribe((pm: ParamMap) => {
+ this.groupName = pm.get('group') ?? '';
+ this.sidebarItems = [
+ {
+ label: $localize`Gateway nodes`,
+ route: [this.basePath, this.groupName, 'nodes'],
+ routerLinkActiveOptions: { exact: true }
+ },
+ {
+ label: $localize`Subsystems`,
+ route: [this.basePath, this.groupName, 'subsystems']
+ }
+ ];
+ });
+ }
+}
<span *ngIf="created">{{ created | date:'EEE d MMM, yyyy' }}</span>
</ng-template>
+<ng-template #customTableItemTemplate
+ let-value="data.value">
+ <a cdsLink
+ [routerLink]="[viewUrl, value | encodeUri]"
+ (click)="$event.stopPropagation()">
+ {{ value }}
+ </a>
+</ng-template>
+
<ng-template #gatewayStatusTpl
let-gateway="data.value">
<div [cdsStack]="'horizontal'"
import { Component, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core';
+import { Router } from '@angular/router';
import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
-import { catchError, map, switchMap, tap } from 'rxjs/operators';
+import { catchError, map, switchMap } 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';
@ViewChild('dateTpl', { static: true })
dateTpl: TemplateRef<any>;
- @ViewChild('gatewayStatusTpl', { static: true })
- gatewayStatusTpl: TemplateRef<any>;
+ @ViewChild('customTableItemTemplate', { static: true })
+ customTableItemTemplate: TemplateRef<any>;
@ViewChild('deleteTpl', { static: true })
deleteTpl: TemplateRef<any>;
+ @ViewChild('gatewayStatusTpl', { static: true })
+ gatewayStatusTpl: TemplateRef<any>;
+
permission: Permission;
tableActions: CdTableAction[];
nodesAvailable = false;
subsystemCount: number;
gatewayCount: number;
+ viewUrl = `/${BASE_URL}/view`;
icons = Icons;
iconSize = IconSize;
private cephServiceService: CephServiceService,
public taskWrapper: TaskWrapperService,
private notificationService: NotificationService,
- private urlBuilder: URLBuilderService
+ private urlBuilder: URLBuilderService,
+ private router: Router
) {}
ngOnInit(): void {
this.columns = [
{
name: $localize`Name`,
- prop: 'name'
+ prop: 'name',
+ cellTemplate: this.customTableItemTemplate
},
{
name: $localize`Gateways`,
canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
};
+ const viewAction: CdTableAction = {
+ permission: 'read',
+ icon: Icons.eye,
+ click: () => this.getViewDetails(),
+ name: $localize`View details`,
+ canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
+ };
+
const deleteAction: CdTableAction = {
permission: 'delete',
icon: Icons.destroy,
name: this.actionLabels.DELETE,
canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection
};
- this.tableActions = [createAction, deleteAction];
+
+ this.tableActions = [createAction, viewAction, deleteAction];
+
this.gatewayGroup$ = this.subject.pipe(
switchMap(() =>
this.nvmeofService.listGatewayGroups().pipe(
call: this.cephServiceService.delete(serviceName)
})
.pipe(
- tap(() => {
+ map(() => {
this.table.refreshBtn();
}),
catchError((error) => {
}
});
}
-
private checkNodesAvailability(): void {
forkJoin([this.nvmeofService.listGatewayGroups(), this.hostService.getAllHosts()]).subscribe(
([groups, hosts]: [GatewayGroup[][], any[]]) => {
}
);
}
+
+ getViewDetails() {
+ const selectedGroup = this.selection.first();
+ if (!selectedGroup) {
+ return;
+ }
+ const groupName = selectedGroup.name;
+ if (!groupName) {
+ return;
+ }
+ this.router.navigate([this.viewUrl, groupName]);
+ }
}
-
-<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>
+<div>
+ <cd-table
+ #table
+ [data]="hosts"
+ [columns]="columns"
+ columnMode="flex"
+ (fetchData)="getHosts($event)"
+ [selectionType]="selectionType"
+ [searchableObjects]="true"
+ [serverSide]="false"
+ [maxLimit]="25"
+ identifier="hostname"
+ forceIdentifier="true"
+ (updateSelection)="updateSelection($event)"
+ >
+ <cd-table-actions
+ class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </cd-table>
+</div>
<ng-template
#addrTpl
let-value="data.value"
let-row="data.row"
>
+@if (value) {
<div
[cdsStack]="'horizontal'"
gap="4"
@if (value === HostStatus.AVAILABLE) {
<cd-icon type="success"></cd-icon>
}
-
<span class="cds-ml-3">{{ value | titlecase }}</span>
</div>
-
+} @else {
+ <span>-</span>
+}
</ng-template>
<ng-template #labelsTpl
import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ActivatedRoute } from '@angular/router';
+import { BehaviorSubject, of, throwError } from 'rxjs';
+
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';
CoreModule,
TagModule
],
- providers: [{ provide: AuthStorageService, useValue: fakeAuthStorageService }]
+ providers: [
+ { provide: AuthStorageService, useValue: fakeAuthStorageService },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ parent: {
+ params: new BehaviorSubject({ group: 'group1' })
+ },
+ data: of({ mode: 'selector' }),
+ snapshot: {
+ data: { mode: 'selector' }
+ }
+ }
+ }
+ ]
});
beforeEach(() => {
it('should initialize with default values', () => {
expect(component.hosts).toEqual([]);
expect(component.isLoadingHosts).toBe(false);
- expect(component.count).toBe(5);
+ expect(component.totalHostCount).toBe(5);
expect(component.permission).toBeDefined();
});
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));
+ const selectedHosts = component.getSelectedHostnames();
expect(selectedHosts.length).toBe(2);
- expect(selectedHosts[0]).toEqual(mockGatewayNodes[0]);
- expect(selectedHosts[1]).toEqual(mockGatewayNodes[1]);
+ expect(selectedHosts[0]).toEqual(mockGatewayNodes[0].hostname);
+ expect(selectedHosts[1]).toEqual(mockGatewayNodes[1].hostname);
});
it('should get selected hostnames', () => {
expect(selectedHostnames).toEqual(['gateway-node-1', 'gateway-node-2']);
});
- it('should load hosts with orchestrator available and facts feature enabled', (done) => {
+ it('should load hosts with orchestrator available and facts feature enabled', fakeAsync(() => {
const hostListSpy = spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
const mockOrcStatus: any = {
available: true,
};
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(nvmeofService, 'listGatewayGroups').and.returnValue(
+ of([
+ [
+ {
+ service_id: 'nvmeof.group1',
+ placement: { hosts: ['gateway-node-1'] }
+ }
+ ]
+ ] as any)
+ );
spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ component.groupName = 'group1';
fixture.detectChanges();
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);
- });
+ tick(100);
+ expect(hostListSpy).toHaveBeenCalled();
+ // Hosts NOT in usedHostnames are included (gateway-node-1 is used, so filtered out)
+ // gateway-node-2 and gateway-node-3 are returned (status is not filtered)
+ expect(component.hosts.length).toBe(2);
+ expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-2');
+ expect(component.hosts.map((h) => h.hostname)).toContain('gateway-node-3');
+ }));
- it('should set count to hosts length', (done) => {
+ it('should set count to hosts length', fakeAsync(() => {
spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
const mockOrcStatus: any = {
available: true,
};
spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+ of([
+ [
+ {
+ service_id: 'nvmeof.group1',
+ placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
+ }
+ ]
+ ] as any)
+ );
spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ component.groupName = 'group1';
fixture.detectChanges();
component.getHosts(new CdTableFetchDataContext(() => undefined));
- setTimeout(() => {
- // Count should equal the filtered hosts length
- expect(component.count).toBe(component.hosts.length);
- done();
- }, 100);
- });
+ tick(100);
+ // Count should equal the filtered hosts length
+ expect(component.totalHostCount).toBe(component.hosts.length);
+ }));
- it('should set count to 0 when no hosts are returned', (done) => {
+ it('should set count to 0 when no hosts are returned', fakeAsync(() => {
spyOn(hostService, 'list').and.returnValue(of([]));
const mockOrcStatus: any = {
available: true,
};
spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+ of([
+ [
+ {
+ service_id: 'nvmeof.group1',
+ placement: { hosts: ['gateway-node-1'] }
+ }
+ ]
+ ] as any)
+ );
spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ component.groupName = 'group1';
fixture.detectChanges();
component.getHosts(new CdTableFetchDataContext(() => undefined));
- setTimeout(() => {
- expect(component.count).toBe(0);
- expect(component.hosts.length).toBe(0);
- done();
- }, 100);
- });
+ tick(100);
+ expect(component.totalHostCount).toBe(0);
+ expect(component.hosts.length).toBe(0);
+ }));
- it('should handle error when fetching hosts', (done) => {
+ it('should handle error when fetching hosts', fakeAsync(() => {
const errorMsg = 'Failed to fetch hosts';
spyOn(hostService, 'list').and.returnValue(throwError(() => new Error(errorMsg)));
const mockOrcStatus: any = {
};
spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+ of([
+ [
+ {
+ service_id: 'nvmeof.group1',
+ placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
+ }
+ ]
+ ] as any)
+ );
spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ component.groupName = 'group1';
fixture.detectChanges();
const context = new CdTableFetchDataContext(() => undefined);
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);
- });
+ tick(100);
+ expect(component.isLoadingHosts).toBe(false);
+ expect(context.error).toHaveBeenCalled();
+ }));
- it('should not re-fetch if already loading', (done) => {
+ it('should not re-fetch if already loading', fakeAsync(() => {
component.isLoadingHosts = true;
const hostListSpy = spyOn(hostService, 'list');
component.getHosts(new CdTableFetchDataContext(() => undefined));
- setTimeout(() => {
- expect(hostListSpy).not.toHaveBeenCalled();
- done();
- }, 100);
- });
+ tick(100);
+ expect(hostListSpy).not.toHaveBeenCalled();
+ }));
- it('should unsubscribe on component destroy', () => {
- const destroy$ = component['destroy$'];
- spyOn(destroy$, 'next');
- spyOn(destroy$, 'complete');
+ it('should unsubscribe on component destroy', fakeAsync(() => {
+ spyOn(hostService, 'list').and.returnValue(of([]));
+ spyOn(orchService, 'status').and.returnValue(of({} as any));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+ tick(100);
+
+ const sub = component['sub'];
+ spyOn(sub, 'unsubscribe');
component.ngOnDestroy();
- expect(destroy$.next).toHaveBeenCalled();
- expect(destroy$.complete).toHaveBeenCalled();
- });
+ expect(sub.unsubscribe).toHaveBeenCalled();
+ }));
- it('should handle host list with various label types', (done) => {
+ it('should handle host list with various label types', fakeAsync(() => {
const hostsWithLabels = [
{
...mockGatewayNodes[0],
};
spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+ of([
+ [
+ {
+ service_id: 'nvmeof.group1',
+ placement: { hosts: ['gateway-node-2'] }
+ }
+ ]
+ ] as any)
+ );
spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ component.groupName = 'group1';
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);
- });
+ tick(100);
+ expect(component.hosts[0]['labels'].length).toBe(3);
+ expect(component.hosts[1]['labels'].length).toBe(0);
+ }));
- it('should handle hosts with multiple services', (done) => {
+ it('should handle hosts with multiple services', fakeAsync(() => {
const hostsWithServices = [
{
...mockGatewayNodes[0],
};
spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+ of([
+ [
+ {
+ service_id: 'nvmeof.group1',
+ placement: { hosts: ['gateway-node-2'] }
+ }
+ ]
+ ] as any)
+ );
spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ component.groupName = 'group1';
fixture.detectChanges();
component.getHosts(new CdTableFetchDataContext(() => undefined));
- setTimeout(() => {
- expect(component.hosts[0]['services'].length).toBe(2);
- done();
- }, 100);
- });
+ tick(100);
+ expect(component.hosts[0]['services'].length).toBe(2);
+ }));
- it('should initialize table context on first getHosts call', (done) => {
+ it('should initialize table context on first getHosts call', fakeAsync(() => {
spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
const mockOrcStatus: any = {
available: true,
};
spyOn(orchService, 'status').and.returnValue(of(mockOrcStatus));
- spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(of([[]]));
+ spyOn(nvmeofService, 'listGatewayGroups').and.returnValue(
+ of([
+ [
+ {
+ service_id: 'nvmeof.group1',
+ placement: { hosts: ['gateway-node-1', 'gateway-node-2'] }
+ }
+ ]
+ ] as any)
+ );
spyOn(hostService, 'checkHostsFactsAvailable').and.returnValue(true);
+ component.groupName = 'group1';
fixture.detectChanges();
- expect((component as any).tableContext).toBeNull();
+ expect((component as any).tableContext).toBeUndefined();
component.getHosts(new CdTableFetchDataContext(() => undefined));
- setTimeout(() => {
- expect((component as any).tableContext).not.toBeNull();
- done();
- }, 100);
- });
+ tick(100);
+ expect((component as any).tableContext).toBeDefined();
+ }));
- it('should reuse table context if already set', (done) => {
+ it('should reuse table context if already set', fakeAsync(() => {
const context = new CdTableFetchDataContext(() => undefined);
spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
const mockOrcStatus: any = {
component.getHosts(context);
- setTimeout(() => {
- const storedContext = (component as any).tableContext;
- expect(storedContext).toBe(context);
- done();
- }, 100);
+ tick(100);
+ const storedContext = (component as any).tableContext;
+ expect(storedContext).toBe(context);
+ }));
+
+ it('should fetch data using fetchHostsAndGroups in details mode', fakeAsync(() => {
+ (component as any).route.data = of({ mode: 'details' });
+ component.ngOnInit();
+ component.groupName = 'group1';
+
+ spyOn(nvmeofService, 'fetchHostsAndGroups').and.returnValue(
+ of({
+ groups: [
+ [
+ {
+ service_id: 'nvmeof.group1',
+ spec: { group: 'group1' },
+ placement: { hosts: ['gateway-node-1'] }
+ }
+ ]
+ ],
+ hosts: mockGatewayNodes
+ } as any)
+ );
+
+ fixture.detectChanges();
+ component.getHosts(new CdTableFetchDataContext(() => undefined));
+ tick(100);
+
+ expect(nvmeofService.fetchHostsAndGroups).toHaveBeenCalled();
+ expect(component.hosts.length).toBe(1);
+ expect(component.hosts[0].hostname).toBe('gateway-node-1');
+ expect(component.hosts[0].hostname).toBe('gateway-node-1');
+ }));
+
+ it('should set selectionType to multiClick in selector mode', () => {
+ (component as any).route.data = of({ mode: 'selector' });
+ component.ngOnInit();
+ expect(component.selectionType).toBe('multiClick');
+ });
+
+ it('should set selectionType to single in details mode', () => {
+ (component as any).route.data = of({ mode: 'details' });
+ component.ngOnInit();
+ expect(component.selectionType).toBe('single');
});
});
import {
Component,
EventEmitter,
+ Input,
OnDestroy,
OnInit,
Output,
TemplateRef,
ViewChild
} from '@angular/core';
-import { forkJoin, Subject } from 'rxjs';
-import { map, mergeMap, takeUntil } from 'rxjs/operators';
+import { ActivatedRoute } from '@angular/router';
+import { forkJoin, Subject, Subscription } from 'rxjs';
+import { finalize, mergeMap } from 'rxjs/operators';
import { HostService } from '~/app/shared/api/host.service';
import { OrchestratorService } from '~/app/shared/api/orchestrator.service';
import { TableComponent } from '~/app/shared/datatable/table/table.component';
import { HostStatus } from '~/app/shared/enum/host-status.enum';
import { Icons } from '~/app/shared/enum/icons.enum';
+import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
+
import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
import { 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',
})
export class NvmeofGatewayNodeComponent implements OnInit, OnDestroy {
@ViewChild(TableComponent, { static: true })
- table: TableComponent;
+ table!: TableComponent;
@ViewChild('hostNameTpl', { static: true })
- hostNameTpl: TemplateRef<any>;
+ hostNameTpl!: TemplateRef<any>;
@ViewChild('statusTpl', { static: true })
- statusTpl: TemplateRef<any>;
+ statusTpl!: TemplateRef<any>;
@ViewChild('addrTpl', { static: true })
- addrTpl: TemplateRef<any>;
+ addrTpl!: TemplateRef<any>;
@ViewChild('labelsTpl', { static: true })
- labelsTpl: TemplateRef<any>;
-
- @ViewChild('orchTmpl', { static: true })
- orchTmpl: TemplateRef<any>;
+ labelsTpl!: TemplateRef<any>;
@Output() selectionChange = new EventEmitter<CdTableSelection>();
@Output() hostsLoaded = new EventEmitter<number>();
+ @Input() groupName: string | undefined;
+ @Input() mode: NvmeofGatewayNodeMode = NvmeofGatewayNodeMode.SELECTOR;
usedHostnames: Set<string> = new Set();
+ serviceSpec: CephServiceSpec | undefined;
permission: Permission;
columns: CdTableColumn[] = [];
hosts: Host[] = [];
isLoadingHosts = false;
- tableActions: CdTableAction[];
+ tableActions: CdTableAction[] = [];
+ selectionType: 'single' | 'multiClick' | 'none' = 'single';
+
selection = new CdTableSelection();
icons = Icons;
HostStatus = HostStatus;
- private tableContext: CdTableFetchDataContext = null;
- count = 5;
- orchStatus: OrchestratorStatus;
+ private tableContext: CdTableFetchDataContext | undefined;
+ totalHostCount = 5;
+ orchStatus: OrchestratorStatus | undefined;
private destroy$ = new Subject<void>();
+ private sub: Subscription | undefined;
constructor(
private authStorageService: AuthStorageService,
private hostService: HostService,
private orchService: OrchestratorService,
- private nvmeofService: NvmeofService
+ private nvmeofService: NvmeofService,
+ private route: ActivatedRoute
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit(): void {
+ this.route.data.subscribe((data) => {
+ if (data?.['mode']) {
+ this.mode = data['mode'];
+ }
+ });
+
+ this.selectionType = this.mode === NvmeofGatewayNodeMode.SELECTOR ? 'multiClick' : 'single';
+
+ if (this.mode === NvmeofGatewayNodeMode.DETAILS) {
+ this.route.parent?.params.subscribe((params: { group: string }) => {
+ this.groupName = params.group;
+ });
+ this.tableActions = [
+ {
+ permission: 'create',
+ icon: Icons.add,
+ click: () => this.addGateway(),
+ name: $localize`Add`,
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ },
+ {
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.removeGateway(),
+ name: $localize`Remove`,
+ disable: (selection: CdTableSelection) => !selection.hasSelection
+ }
+ ];
+ }
+
this.columns = [
{
name: $localize`Hostname`,
cellTemplate: this.statusTpl
},
{
- name: $localize`Labels`,
+ name: $localize`Labels (tags)`,
prop: 'labels',
flexGrow: 1,
cellTemplate: this.labelsTpl
];
}
+ addGateway(): void {
+ // TODO
+ }
+
+ removeGateway(): void {
+ // TODO
+ }
+
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
+ if (this.sub) {
+ this.sub.unsubscribe();
+ }
}
updateSelection(selection: CdTableSelection): void {
}
getHosts(context: CdTableFetchDataContext): void {
- if (context !== null) {
- this.tableContext = context;
- }
- if (this.tableContext == null) {
- this.tableContext = new CdTableFetchDataContext(() => undefined);
- }
+ this.tableContext =
+ context || 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),
- () => {
+ if (this.sub) {
+ this.sub.unsubscribe();
+ }
+
+ const fetchData$ =
+ this.mode === NvmeofGatewayNodeMode.DETAILS
+ ? this.nvmeofService.fetchHostsAndGroups()
+ : forkJoin({
+ groups: this.nvmeofService.listGatewayGroups(),
+ hosts: this.orchService.status().pipe(
+ mergeMap((orchStatus: OrchestratorStatus) => {
+ this.orchStatus = orchStatus;
+ const factsAvailable = this.hostService.checkHostsFactsAvailable(orchStatus);
+ return this.hostService.list(
+ this.tableContext?.toParams(),
+ factsAvailable.toString()
+ );
+ })
+ )
+ });
+
+ this.sub = fetchData$
+ .pipe(
+ finalize(() => {
this.isLoadingHosts = false;
- context.error();
- }
- );
+ })
+ )
+ .subscribe({
+ next: (result: any) => {
+ this.mode === NvmeofGatewayNodeMode.DETAILS
+ ? this.processHostsForDetailsMode(result.groups, result.hosts)
+ : this.processHostsForSelectorMode(result.groups, result.hosts);
+ },
+ error: () => 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;
- })
- );
+ /**
+ * Selector Mode: Used in 'Add/Create' forms.
+ * Filters the entire cluster inventory to show only **available** candidates
+ * (excluding nodes that are already part of a gateway group).
+ */
+ private processHostsForSelectorMode(groups: CephServiceSpec[][] = [[]], hostList: Host[] = []) {
+ const usedHosts = new Set<string>();
+ (groups?.[0] ?? []).forEach((group: CephServiceSpec) => {
+ group.placement?.hosts?.forEach((hostname: string) => usedHosts.add(hostname));
+ });
+ this.usedHostnames = usedHosts;
+
+ this.hosts = (hostList || []).filter((host: Host) => !this.usedHostnames.has(host.hostname));
+
+ this.updateCount();
}
- 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());
- })
+ /**
+ * Details Mode: Used in 'Details' views.
+ * Filters specifically for the nodes that are **configured members**
+ * of the current gateway group, regardless of their status.
+ */
+ private processHostsForDetailsMode(groups: any[][], hostList: Host[]) {
+ const groupList = groups?.[0] ?? [];
+ const currentGroup: CephServiceSpec | undefined = groupList.find(
+ (group: CephServiceSpec) => group.spec?.group === this.groupName
);
- }
- 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;
+ if (!currentGroup) {
+ this.hosts = [];
+ } else {
+ const placementHosts =
+ currentGroup.placement?.hosts || (currentGroup.spec as any)?.placement?.hosts || [];
+ const currentGroupHosts = new Set<string>(placementHosts);
+
+ this.hosts = (hostList || []).filter((host: Host) => {
+ return currentGroupHosts.has(host.hostname);
});
+ }
- this.isLoadingHosts = false;
- this.count = this.hosts.length;
- this.hostsLoaded.emit(this.count);
+ this.serviceSpec = currentGroup;
+ this.updateCount();
}
- checkHostsFactsAvailable(): boolean {
- return this.hostService.checkHostsFactsAvailable(this.orchStatus);
+ private updateCount(): void {
+ this.totalHostCount = this.hosts.length;
+ this.hostsLoaded.emit(this.totalHostCount);
}
}
--- /dev/null
+<div>
+ <cd-table
+ [data]="subsystems"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="none"
+ identifier="nqn"
+ [forceIdentifier]="true"
+ [serverSide]="false"
+ [maxLimit]="25"
+ (updateSelection)="updateSelection($event)"
+ emptyStateTitle="No subsystems linked yet."
+ i18n-emptyStateTitle
+ emptyStateMessage="Once a subsystem is associated, it will appear in this list."
+ i18n-emptyStateMessage
+ [emptyStateIcon]="iconType.emptySearch"
+ >
+ </cd-table>
+</div>
+
+<ng-template #authTpl
+ let-row="data.row">
+ <div cdsStack="horizontal"
+ gap="4">
+ @if (row.auth === authType.NO_AUTH) {
+ <cd-icon type="warning"></cd-icon>
+ } @else {
+ <cd-icon type="success"></cd-icon>
+ }
+ <span class="cds-ml-3">{{ row.auth }}</span>
+ </div>
+</ng-template>
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+
+import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('NvmeofGatewaySubsystemComponent', () => {
+ let component: NvmeofGatewaySubsystemComponent;
+ let fixture: ComponentFixture<NvmeofGatewaySubsystemComponent>;
+ let nvmeofService: NvmeofService;
+
+ const mockSubsystems: NvmeofSubsystem[] = [
+ {
+ nqn: 'nqn.2014-08.org.nvmexpress:uuid:1111',
+ enable_ha: true,
+ allow_any_host: true,
+ gw_group: 'group1',
+ serial_number: 'SN001',
+ model_number: 'MN001',
+ min_cntlid: 1,
+ max_cntlid: 65519,
+ max_namespaces: 256,
+ namespace_count: 0,
+ subtype: 'NVMe',
+ namespaces: []
+ } as NvmeofSubsystem,
+ {
+ nqn: 'nqn.2014-08.org.nvmexpress:uuid:2222',
+ enable_ha: false,
+ allow_any_host: false,
+ gw_group: 'group1',
+ serial_number: 'SN002',
+ model_number: 'MN002',
+ min_cntlid: 1,
+ max_cntlid: 65519,
+ max_namespaces: 256,
+ namespace_count: 0,
+ subtype: 'NVMe',
+ namespaces: []
+ } as NvmeofSubsystem
+ ];
+
+ const mockInitiators1 = [{ nqn: 'host1' }, { nqn: 'host2' }];
+ const mockInitiators2 = [{ nqn: 'host3' }];
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofGatewaySubsystemComponent],
+ imports: [HttpClientTestingModule, SharedModule],
+ providers: [
+ {
+ provide: NvmeofService,
+ useValue: {
+ listSubsystems: jest.fn(() => of(mockSubsystems)),
+ getInitiators: jest.fn((nqn) => {
+ if (nqn === 'nqn.2014-08.org.nvmexpress:uuid:1111') {
+ return of(mockInitiators1);
+ }
+ return of(mockInitiators2);
+ })
+ }
+ },
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ parent: {
+ params: of({ group: 'group1' })
+ }
+ }
+ }
+ ]
+ }).compileComponents();
+
+ nvmeofService = TestBed.inject(NvmeofService);
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeofGatewaySubsystemComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should verify getData fetches and processes data correctly', () => {
+ component.groupName = 'direct-test-group';
+ component.getSubsystemsData();
+
+ expect(nvmeofService.listSubsystems).toHaveBeenCalledWith('direct-test-group');
+ expect(component.subsystems.length).toBe(2);
+ expect(component.subsystems[0].nqn).toBe(mockSubsystems[0].nqn);
+ });
+});
--- /dev/null
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { forkJoin, of } from 'rxjs';
+import { catchError, map, switchMap } from 'rxjs/operators';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import {
+ NvmeofSubsystem,
+ NvmeofSubsystemData,
+ NvmeofSubsystemInitiator
+} from '~/app/shared/models/nvmeof';
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+
+import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum';
+
+@Component({
+ selector: 'cd-nvmeof-gateway-subsystem',
+ templateUrl: './nvmeof-gateway-subsystem.component.html',
+ styleUrls: ['./nvmeof-gateway-subsystem.component.scss'],
+ standalone: false
+})
+export class NvmeofGatewaySubsystemComponent implements OnInit {
+ @ViewChild('authTpl', { static: true })
+ authTpl!: TemplateRef<any>;
+
+ groupName!: string;
+
+ columns: CdTableColumn[] = [];
+
+ subsystems: NvmeofSubsystemData[] = [];
+ selection = new CdTableSelection();
+
+ iconType = ICON_TYPE;
+ authType = NvmeofSubsystemAuthType;
+
+ constructor(private nvmeofService: NvmeofService, private route: ActivatedRoute) {}
+
+ ngOnInit(): void {
+ this.columns = [
+ {
+ name: $localize`Subsystem NQN`,
+ prop: 'nqn',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Authentication`,
+ prop: 'auth',
+ flexGrow: 1.5,
+ cellTemplate: this.authTpl
+ },
+ {
+ name: $localize`Hosts (Initiators)`,
+ prop: 'hosts',
+ flexGrow: 1
+ }
+ ];
+
+ this.route.parent?.params.subscribe((params) => {
+ if (params['group']) {
+ this.groupName = params['group'];
+ this.getSubsystemsData();
+ }
+ });
+ }
+
+ getSubsystemsData() {
+ this.nvmeofService
+ .listSubsystems(this.groupName)
+ .pipe(
+ switchMap((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => {
+ const subs = Array.isArray(subsystems) ? subsystems : [subsystems];
+ if (subs.length === 0) return of([]);
+
+ return forkJoin(
+ subs.map((sub) =>
+ this.nvmeofService.getInitiators(sub.nqn, this.groupName).pipe(
+ catchError(() => of([])),
+ map(
+ (
+ initiators: NvmeofSubsystemInitiator[] | { hosts?: NvmeofSubsystemInitiator[] }
+ ) => {
+ let count = 0;
+ if (Array.isArray(initiators)) count = initiators.length;
+ else if (initiators?.hosts && Array.isArray(initiators.hosts)) {
+ count = initiators.hosts.length;
+ }
+
+ let authStatus = NvmeofSubsystemAuthType.NO_AUTH;
+ if (sub.psk) {
+ authStatus = NvmeofSubsystemAuthType.BIDIRECTIONAL;
+ } else if (
+ initiators &&
+ 'hosts' in initiators &&
+ Array.isArray(initiators.hosts)
+ ) {
+ const hasDhchapKey = initiators.hosts.some(
+ (host: NvmeofSubsystemInitiator) => !!host.dhchap_key
+ );
+ if (hasDhchapKey) {
+ authStatus = NvmeofSubsystemAuthType.UNIDIRECTIONAL;
+ }
+ } else if (Array.isArray(initiators)) {
+ // Fallback for unexpected structure, though getInitiators usually returns {hosts: []}
+ const hasDhchapKey = (initiators as NvmeofSubsystemInitiator[]).some(
+ (host: NvmeofSubsystemInitiator) => !!host.dhchap_key
+ );
+ if (hasDhchapKey) {
+ authStatus = NvmeofSubsystemAuthType.UNIDIRECTIONAL;
+ }
+ }
+
+ return {
+ ...sub,
+ auth: authStatus,
+ hosts: count
+ };
+ }
+ )
+ )
+ )
+ );
+ })
+ )
+ .subscribe({
+ next: (subsystems: NvmeofSubsystemData[]) => {
+ this.subsystems = subsystems;
+ },
+ error: () => {
+ this.subsystems = [];
+ }
+ });
+ }
+
+ updateSelection(selection: CdTableSelection): void {
+ this.selection = selection;
+ }
+}
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';
+import { OrchestratorStatus } from '../models/orchestrator.interface';
@Injectable({
providedIn: 'root'
return this.http.get<Host[]>(`${this.baseUIURL}/list`);
}
- checkHostsFactsAvailable(orchStatus?: OrchestratorStatus): boolean {
- const orchFeatures = orchStatus?.features;
- if (!_.isEmpty(orchFeatures)) {
- return !!orchFeatures.get_facts?.available;
+ checkHostsFactsAvailable(orchStatus: OrchestratorStatus) {
+ if (orchStatus?.available) {
+ return true;
}
return false;
}
import { HttpClient } from '@angular/common/http';
import _ from 'lodash';
-import { Observable, of as observableOf } from 'rxjs';
+import { Observable, forkJoin, of as observableOf } from 'rxjs';
import { catchError, map, mapTo } from 'rxjs/operators';
import { CephServiceSpec } from '../models/service.interface';
+import { HostService } from './host.service';
export const DEFAULT_MAX_NAMESPACE_PER_SUBSYSTEM = 512;
providedIn: 'root'
})
export class NvmeofService {
- constructor(private http: HttpClient) {}
+ constructor(private http: HttpClient, private hostService: HostService) {}
+
+ fetchHostsAndGroups() {
+ return forkJoin({
+ groups: this.listGatewayGroups(),
+ hosts: this.hostService.getAllHosts()
+ });
+ }
// formats the gateway groups to be consumed for combobox item
formatGwGroupsList(
import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
import { ProductiveCardComponent } from './productive-card/productive-card.component';
import { PageHeaderComponent } from './page-header/page-header.component';
+import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component';
@NgModule({
imports: [
TearsheetComponent,
TearsheetStepComponent,
ProductiveCardComponent,
- PageHeaderComponent
+ PageHeaderComponent,
+ SidebarLayoutComponent
],
providers: [provideCharts(withDefaultRegisterables())],
exports: [
TearsheetComponent,
TearsheetStepComponent,
ProductiveCardComponent,
- PageHeaderComponent
+ PageHeaderComponent,
+ SidebarLayoutComponent
]
})
export class ComponentsModule {
.notificationNew-icon circle {
fill: theme.$support-error !important;
}
+
+.emptySearch-icon {
+ fill: theme.$layer-selected-disabled !important;
+}
--- /dev/null
+@if (title) {
+<header class="sidebar-header">
+ <h2 class="cds--type-heading-05">{{ title }}</h2>
+</header>
+}
+<div class="sidebar-layout-container">
+ <div class="sidebar-layout-shell">
+ <cds-sidenav [expanded]="true">
+ @for (item of items; track item.label) {
+ <cds-sidenav-item
+ [route]="item.route"
+ [useRouter]="true"
+ [routerLinkActiveOptions]="item.routerLinkActiveOptions || { exact: false }">
+ <span class="cds--type-heading-compact-01">{{ item.label }}</span>
+ </cds-sidenav-item>
+ }
+ </cds-sidenav>
+
+ <main class="sidebar-layout-main">
+ <router-outlet></router-outlet>
+ </main>
+ </div>
+</div>
--- /dev/null
+@use './src/styles/vendor/variables' as vv;
+@use '@carbon/colors';
+@use '@carbon/layout';
+
+.sidebar-layout-container {
+ min-height: calc(100vh - (vv.$navbar-height + layout.rem(55px)));
+ background-color: var(--cds-background);
+ padding-right: var(--cds-spacing-07);
+}
+
+.sidebar-layout-shell {
+ transform: translate(0);
+ position: relative;
+}
+
+.sidebar-layout-main {
+ margin-left: layout.rem(272px);
+}
+
+.sidebar-header {
+ padding-left: var(--cds-spacing-05);
+}
--- /dev/null
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+import { SidebarLayoutComponent } from './sidebar-layout.component';
+
+describe('SidebarLayoutComponent', () => {
+ let component: SidebarLayoutComponent;
+ let fixture: ComponentFixture<SidebarLayoutComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [SidebarLayoutComponent],
+ imports: [RouterTestingModule, SideNavModule, ThemeModule]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(SidebarLayoutComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, Input, ViewEncapsulation } from '@angular/core';
+
+export interface SidebarItem {
+ label: string;
+ route: string[];
+ routerLinkActiveOptions?: { exact: boolean };
+}
+
+@Component({
+ selector: 'cd-sidebar-layout',
+ templateUrl: './sidebar-layout.component.html',
+ styleUrls: ['./sidebar-layout.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ standalone: false,
+ host: {
+ class: 'tearsheet--full'
+ }
+})
+export class SidebarLayoutComponent {
+ @Input() title!: string;
+ @Input() items: SidebarItem[] = [];
+}
/* Non-standard verbs */
COPY = 'copy',
CLONE = 'clone',
+ VIEW = 'view',
/* Prometheus wording */
RECREATE = 'recreate',
/* Multi-cluster */
CONNECT = 'connect',
- RECONNECT = 'reconnect'
+ RECONNECT = 'reconnect',
+ VIEW = 'View'
}
@Injectable({
EXPAND_CLUSTER: string;
SETUP_MULTISITE_REPLICATION: string;
NFS_EXPORT: string;
-
+ VIEW: string;
constructor() {
/* Create a new item */
this.CREATE = $localize`Create`;
this.EXPAND_CLUSTER = $localize`Expand Cluster`;
this.NFS_EXPORT = $localize`Create NFS Export`;
+ this.VIEW = $localize`View`;
}
}
notification = 'notification',
error = 'error--filled',
notificationOff = 'notification--off',
- notificationNew = 'notification--new'
+ notificationNew = 'notification--new',
+ emptySearch = 'search'
}
export enum IconSize {
notificationNew: 'notification--new',
success: 'success',
warning: 'warning',
- add: 'add'
+ add: 'add',
+ emptySearch: 'emptySearch'
} as const;
--- /dev/null
+export enum NvmeofSubsystemAuthType {
+ NO_AUTH = 'No authentication',
+ UNIDIRECTIONAL = 'Unidirectional',
+ BIDIRECTIONAL = 'Bi-directional'
+}
+
+export enum NvmeofGatewayNodeMode {
+ SELECTOR = 'selector',
+ DETAILS = 'details'
+}
namespace_count: number;
subtype: string;
max_namespaces: number;
+ allow_any_host?: boolean;
+ enable_ha?: boolean;
+ gw_group?: string;
+ initiator_count?: number;
+ psk?: string;
+}
+
+export interface NvmeofSubsystemData extends NvmeofSubsystem {
+ auth?: string;
+ hosts?: number;
}
export interface NvmeofSubsystemInitiator {
nqn: string;
+ dhchap_key?: string;
}
export interface NvmeofListener {
getReconnect(item: string, absolute = true): string {
return this.getURL(URLVerbs.RECONNECT, absolute, item);
}
+
+ getView(absolute = true): string {
+ return this.getURL(URLVerbs.VIEW, absolute);
+ }
}
.cds-mr-5 {
margin-right: layout.$spacing-05;
}
+
+.cds-pt-6 {
+ padding-top: layout.$spacing-06;
+}