import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
+import { NgModule, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule, Routes } from '@angular/router';
SelectModule,
UIShellModule,
TreeviewModule,
+ SideNavModule,
TabsModule,
+ ThemeModule,
TagModule
} from 'carbon-components-angular';
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 { 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';
+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';
@NgModule({
imports: [
DatePickerModule,
ComboBoxModule,
TabsModule,
+ TabsModule,
+ SideNavModule,
+ ThemeModule,
+
TagModule,
GridModule
],
NvmeofInitiatorsListComponent,
NvmeofInitiatorsFormComponent,
NvmeofGatewayNodeComponent,
- NvmeofGroupFormComponent
+ NvmeofGroupFormComponent,
+ NvmeGatewayViewComponent,
+ NvmeofGatewaySubsystemComponent
+
],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
export class BlockModule {
SubtractFilled,
Reset,
ProgressBarRound,
- SubtractAlt
+ SubtractAlt,
+ Search
]);
}
}
children: [
{ path: '', redirectTo: 'gateways', pathMatch: 'full' },
{ path: 'gateways', 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: `gateways/${URLVerbs.VIEW}/:group`,
+ component: NvmeGatewayViewComponent,
+ data: { breadcrumbs: `${ActionLabels.VIEW}${URLVerbs.GATEWAY_GROUP}` },
+ children: [
+ {
+ path: '',
+ component: NvmeofGatewayNodeComponent,
+ data: { breadcrumbs: NvmeGatewayViewBreadcrumbResolver }
+ },
+ {
+ path: 'subsystems',
+ component: NvmeofSubsystemsDetailsComponent,
+ data: { breadcrumbs: '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
+<header class="cds-mb-5">
+ <h2 class="cds--type-heading-05">{{ groupName }}</h2>
+</header>
+<div class="nvme-shell">
+ <div>
+ <cds-sidenav [expanded]="true">
+ <cds-sidenav-item
+ [active]="selectedTab === 'gateways'"
+ (click)="selectTab('gateways')">
+ <span class="cds--type-heading-compact-01">Gateway nodes</span>
+ </cds-sidenav-item>
+ <cds-sidenav-item
+ [active]="selectedTab === 'subsystems'"
+ (click)="selectTab('subsystems')">
+ <span class="cds--type-heading-compact-01">Subsystems</span>
+ </cds-sidenav-item>
+ </cds-sidenav>
+ </div>
+
+ <main class="nvme-main">
+ @if (selectedTab === 'gateways') {
+ <cd-nvmeof-gateway-node></cd-nvmeof-gateway-node>
+ }
+ @if (selectedTab === 'subsystems') {
+ <cd-nvmeof-gateway-subsystem
+ [groupName]="groupName">
+ </cd-nvmeof-gateway-subsystem>
+ }
+ </main>
+</div>
--- /dev/null
+@use '@carbon/colors';
+@use '@carbon/layout';
+
+.nvme-shell {
+ min-height: calc(100vh - #{layout.rem(157px)});
+ background-color: colors.$gray-10;
+ transform: translate(0);
+ position: relative;
+}
+
+.nvme-main {
+ margin-left: layout.rem(272px);
+}
--- /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';
+
+@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([]);
+ selectedTab: string | null = 'gateways';
+ constructor(private route: ActivatedRoute) {}
+
+ ngOnInit() {
+ this.route.paramMap.subscribe((pm: ParamMap) => {
+ this.groupName = pm.get('group') ?? '';
+ });
+ }
+
+ selectTab(tab: string): void {
+ this.selectedTab = tab;
+ }
+}
<span *ngIf="created">{{ created | date:'EEE d MMM, yyyy' }}</span>
</ng-template>
+<ng-template #customTableItemTemplate
+ let-value="data.value">
+ <a class="cds--link"
+ [routerLink]="['/block/nvmeof/gateways/view', 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 { BehaviorSubject, forkJoin, Observable, of } from 'rxjs';
-import { catchError, map, switchMap, tap } from 'rxjs/operators';
+import { Router } from '@angular/router';
+import { BehaviorSubject, forkJoin, Observable, of, timer } from 'rxjs';
+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';
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({
@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;
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(() => {
+ switchMap(() => timer(25000)),
+ 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.spec?.group ?? selectedGroup.name ?? null;
+ if (!groupName) {
+ return;
+ }
+ const url = `/block/nvmeof/gateways/view/${encodeURIComponent(groupName)}`;
+ this.router.navigateByUrl(url);
+ }
}
-
-<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 class="cds-mt-5">
+ <cd-table
+ #table
+ [data]="hosts"
+ [columns]="columns"
+ columnMode="flex"
+ (fetchData)="getHosts($event)"
+ selectionType="none"
+ [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
import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { RouterTestingModule } from '@angular/router/testing';
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.getSelectedHosts();
expect(selectedHosts.length).toBe(2);
expect(selectedHosts[0]).toEqual(mockGatewayNodes[0]);
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,
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);
- });
+ tick(100);
+ 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('available');
+ }));
+
+ it('should normalize empty status to "available"', fakeAsync(() => {
- it('should normalize empty status to "available"', (done) => {
spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
const mockOrcStatus: any = {
available: true,
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);
+ // 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('available');
+ }));
+
+ it('should set count to hosts length', fakeAsync(() => {
- it('should set count to hosts length', (done) => {
spyOn(hostService, 'list').and.returnValue(of(mockGatewayNodes));
const mockOrcStatus: any = {
available: true,
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.count).toBe(component.hosts.length);
+ }));
+
+ it('should set count to 0 when no hosts are returned', fakeAsync(() => {
- it('should set count to 0 when no hosts are returned', (done) => {
spyOn(hostService, 'list').and.returnValue(of([]));
const mockOrcStatus: any = {
available: true,
component.getHosts(new CdTableFetchDataContext(() => undefined));
- setTimeout(() => {
- expect(component.count).toBe(0);
- expect(component.hosts.length).toBe(0);
- done();
- }, 100);
- });
+ tick(100);
+ expect(component.count).toBe(0);
+ expect(component.hosts.length).toBe(0);
+ }));
+
+ it('should handle error when fetching hosts', fakeAsync(() => {
- 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 = {
component.getHosts(context);
- setTimeout(() => {
- expect(component.isLoadingHosts).toBe(false);
- expect(context.error).toHaveBeenCalled();
- done();
- }, 100);
- });
+ tick(100);
+ expect(component.isLoadingHosts).toBe(false);
+ expect(context.error).toHaveBeenCalled();
+ }));
+
it('should check hosts facts available when orchestrator features present', () => {
component.orchStatus = {
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', () => {
+ it('should return true even when get_facts feature is not available', () => {
+
component.orchStatus = {
available: true,
features: new Map([['other_feature', { available: true }]])
const result = component.checkHostsFactsAvailable();
- expect(result).toBe(false);
+ expect(result).toBe(true);
});
- it('should return false when orchestrator status features are empty', () => {
+ it('should return true even when orchestrator status features are empty', () => {
+
component.orchStatus = {
available: true,
features: new Map()
const result = component.checkHostsFactsAvailable();
- expect(result).toBe(false);
+ expect(result).toBe(true);
+
});
it('should return false when orchestrator status is undefined', () => {
expect(result).toBe(false);
});
- 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');
+ 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],
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],
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,
component.getHosts(new CdTableFetchDataContext(() => undefined));
- setTimeout(() => {
- expect((component as any).tableContext).not.toBeNull();
- done();
- }, 100);
- });
+ tick(100);
+ expect((component as any).tableContext).not.toBeNull();
+ }));
- 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);
+ }));
+
});
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',
})
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>();
columns: CdTableColumn[] = [];
hosts: Host[] = [];
isLoadingHosts = false;
- tableActions: CdTableAction[];
+ tableActions!: CdTableAction[];
+
selection = new CdTableSelection();
icons = Icons;
HostStatus = HostStatus;
}
ngOnInit(): void {
+ 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: Logic to open add gateway modal
+ }
+
+ removeGateway(): void {
+ // TODO: Logic to remove gateway
+ }
+
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
--- /dev/null
+<div class="cds-mt-5">
+ <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."
+ emptyStateMessage="Once a subsystem is associated, it will appear in this list."
+ [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 { 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);
+ })
+ }
+ }
+ ]
+ }).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,
+ Input,
+ OnInit,
+ OnChanges,
+ SimpleChanges,
+ TemplateRef,
+ ViewChild
+} from '@angular/core';
+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 { Icons, 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, OnChanges {
+ @ViewChild('authTpl', { static: true })
+ authTpl!: TemplateRef<any>;
+
+ @Input() groupName: string;
+
+ columns: CdTableColumn[] = [];
+
+ subsystems: NvmeofSubsystemData[] = [];
+ selection = new CdTableSelection();
+ icons = Icons;
+ iconType = ICON_TYPE;
+ authType = NvmeofSubsystemAuthType;
+
+ constructor(private nvmeofService: NvmeofService) {}
+
+ 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
+ }
+ ];
+ }
+
+ ngOnChanges(changes: SimpleChanges): void {
+ if (changes.groupName && this.groupName) {
+ 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.BIDIRECTIONAL;
+ if (sub.enable_ha === false) {
+ authStatus = NvmeofSubsystemAuthType.NO_AUTH;
+ } else if (sub.allow_any_host) {
+ 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';
+
@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: any) {
+ if (orchStatus?.available) {
+ return true;
+
}
return false;
}
.deploy-icon {
fill: theme.$layer-selected-disabled !important;
}
+.emptySearch-icon {
+ fill: theme.$layer-selected-disabled !important;
+}
/* Non-standard verbs */
COPY = 'copy',
CLONE = 'clone',
+ VIEW = 'view',
/* Prometheus wording */
RECREATE = 'recreate',
CONNECT = 'connect',
RECONNECT = 'reconnect',
GATEWAY_GROUP = 'Gateway group'
+
}
export enum ActionLabels {
/* 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`;
}
}
spin = 'fa fa-spin', // To get any icon to rotate
inverse = 'fa fa-inverse', // To get an alternative icon color
notification = 'notification',
- error = 'error--filled'
+ error = 'error--filled',
+ emptySearch = 'search'
}
export enum IconSize {
infoCircle: 'info-circle',
notification: 'notification',
success: 'success',
- warning: 'warning'
+ warning: 'warning',
+ emptySearch: 'emptySearch'
} as const;
--- /dev/null
+export enum NvmeofSubsystemAuthType {
+ NO_AUTH = 'No authentication',
+ UNIDIRECTIONAL = 'Unidirectional',
+ BIDIRECTIONAL = 'Bi-directional'
+}
namespace_count: number;
subtype: string;
max_namespaces: number;
+ allow_any_host?: boolean;
+ enable_ha?: boolean;
+ gw_group?: string;
+ initiator_count?: number;
+}
+
+export interface NvmeofSubsystemData extends NvmeofSubsystem {
+ auth?: string;
+ hosts?: number;
}
export interface NvmeofSubsystemInitiator {
getReconnect(item: string, absolute = true): string {
return this.getURL(URLVerbs.RECONNECT, absolute, item);
}
+
+ getView(absolute = true): string {
+ return this.getURL(URLVerbs.VIEW, absolute);
+ }
}
.cds-mt-5 {
margin-top: layout.$spacing-05;
}
+.cds-pt-6 {
+ padding-top: layout.$spacing-06;
+}