import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver';
import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum';
import { NvmeofGatewayNodeAddModalComponent } from './nvmeof-gateway-node/nvmeof-gateway-node-add-modal/nvmeof-gateway-node-add-modal.component';
+import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list/nvmeof-subsystem-namespaces-list.component';
+import { NvmeSubsystemViewBreadcrumbResolver } from './nvme-subsystem-view/nvme-subsystem-view-breadcrumb.resolver';
+import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem-view.component';
@NgModule({
imports: [
TagModule,
GridModule,
LayerModule,
- LayoutModule,
ContainedListModule,
SideNavModule,
- ThemeModule,
- LayoutModule
+ LayoutModule,
+ ThemeModule
],
declarations: [
RbdListComponent,
NvmeofListenersFormComponent,
NvmeofListenersListComponent,
NvmeofNamespacesListComponent,
+ NvmeofSubsystemNamespacesListComponent,
NvmeofNamespacesFormComponent,
NvmeofInitiatorsListComponent,
NvmeofInitiatorsFormComponent,
NvmeofSubsystemsStepThreeComponent,
NvmeGatewayViewComponent,
NvmeofGatewaySubsystemComponent,
- NvmeofGatewayNodeAddModalComponent
+ NvmeofGatewayNodeAddModalComponent,
+ NvmeSubsystemViewComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
outlet: 'modal'
}
]
+ },
+ {
+ path: `subsystems/:subsystem_nqn`,
+ component: NvmeSubsystemViewComponent,
+ data: { breadcrumbs: NvmeSubsystemViewBreadcrumbResolver },
+ children: [
+ { path: '', redirectTo: 'namespaces', pathMatch: 'full' },
+ {
+ path: 'hosts',
+ component: NvmeofInitiatorsListComponent
+ },
+ {
+ path: 'namespaces',
+ component: NvmeofSubsystemNamespacesListComponent
+ },
+ {
+ path: 'listeners',
+ component: NvmeofListenersListComponent
+ }
+ ]
}
]
}
--- /dev/null
+import { TestBed } from '@angular/core/testing';
+
+import { NvmeSubsystemViewBreadcrumbResolver } from './nvme-subsystem-view-breadcrumb.resolver';
+
+describe('NvmeSubsystemViewBreadcrumbResolver', () => {
+ let resolver: NvmeSubsystemViewBreadcrumbResolver;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({});
+ resolver = TestBed.inject(NvmeSubsystemViewBreadcrumbResolver);
+ });
+
+ it('should be created', () => {
+ expect(resolver).toBeTruthy();
+ });
+});
--- /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 NvmeSubsystemViewBreadcrumbResolver extends BreadcrumbsResolver {
+ resolve(route: ActivatedRouteSnapshot): IBreadcrumb[] {
+ const subsystemNQN = route.parent?.params?.subsystem_nqn || route.params?.subsystem_nqn;
+ return [{ text: decodeURIComponent(subsystemNQN || ''), path: null }];
+ }
+}
--- /dev/null
+<cd-sidebar-layout
+ [title]="subsystemNQN"
+ [items]="sidebarItems"
+></cd-sidebar-layout>
--- /dev/null
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
+import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SideNavModule, ThemeModule } from 'carbon-components-angular';
+
+import { NvmeSubsystemViewComponent } from './nvme-subsystem-view.component';
+
+describe('NvmeSubsystemViewComponent', () => {
+ let component: NvmeSubsystemViewComponent;
+ let fixture: ComponentFixture<NvmeSubsystemViewComponent>;
+
+ beforeEach(
+ waitForAsync(() => {
+ TestBed.configureTestingModule({
+ declarations: [NvmeSubsystemViewComponent],
+ imports: [RouterTestingModule, SideNavModule, ThemeModule],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
+ }).compileComponents();
+ })
+ );
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeSubsystemViewComponent);
+ 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 { SidebarItem } from '~/app/shared/components/sidebar-layout/sidebar-layout.component';
+
+@Component({
+ selector: 'cd-nvme-subsystem-view',
+ templateUrl: './nvme-subsystem-view.component.html',
+ styleUrls: ['./nvme-subsystem-view.component.scss'],
+ encapsulation: ViewEncapsulation.None,
+ standalone: false
+})
+export class NvmeSubsystemViewComponent implements OnInit {
+ subsystemNQN: string;
+ groupName: string;
+ public readonly basePath = '/block/nvmeof/subsystems';
+ sidebarItems: SidebarItem[] = [];
+
+ constructor(private route: ActivatedRoute) {}
+
+ ngOnInit() {
+ this.route.paramMap.subscribe((pm: ParamMap) => {
+ this.subsystemNQN = pm.get('subsystem_nqn') ?? '';
+ });
+ this.route.queryParams.subscribe((qp) => {
+ this.groupName = qp['group'] ?? '';
+ this.buildSidebarItems();
+ });
+ }
+
+ private buildSidebarItems() {
+ const extras = { queryParams: { group: this.groupName } };
+ this.sidebarItems = [
+ {
+ label: $localize`Initiators`,
+ route: [this.basePath, this.subsystemNQN, 'hosts'],
+ routeExtras: extras
+ },
+ {
+ label: $localize`Namespaces`,
+ route: [this.basePath, this.subsystemNQN, 'namespaces'],
+ routeExtras: extras
+ },
+ {
+ label: $localize`Listeners`,
+ route: [this.basePath, this.subsystemNQN, 'listeners'],
+ routeExtras: extras
+ }
+ ];
+ }
+}
const subsystemsObservable = isRunning
? 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([]);
})
)
);
}),
catchError(() => {
- this.notificationService.show(
- NotificationType.error,
- $localize`Unable to fetch Gateway group`,
- $localize`Gateway group does not exist`
- );
return of([]);
})
)
);
this.checkNodesAvailability();
}
-
fetchData(): void {
this.subject.next([]);
this.checkNodesAvailability();
heading="Gateway groups"
[tabContent]="gateways_content"
i18n-heading
+ [active]="activeTab === Tabs.gateways"
(selected)="onSelected(Tabs.gateways)">
</cds-tab>
<cds-tab
heading="Subsystem"
[tabContent]="subsystem_content"
i18n-heading
+ [active]="activeTab === Tabs.subsystem"
(selected)="onSelected(Tabs.subsystem)">
</cds-tab>
<cds-tab
heading="Namespace"
[tabContent]="namespace_content"
i18n-heading
+ [active]="activeTab === Tabs.namespace"
(selected)="onSelected(Tabs.namespace)">
</cds-tab>
</cds-tabs>
import { NvmeofGatewayComponent } from './nvmeof-gateway.component';
import { HttpClientModule } from '@angular/common/http';
+import { RouterTestingModule } from '@angular/router/testing';
import { SharedModule } from '~/app/shared/shared.module';
import { ComboBoxModule, GridModule, TabsModule } from 'carbon-components-angular';
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NvmeofGatewayComponent],
- imports: [HttpClientModule, SharedModule, ComboBoxModule, GridModule, TabsModule],
+ imports: [
+ HttpClientModule,
+ RouterTestingModule,
+ SharedModule,
+ ComboBoxModule,
+ GridModule,
+ TabsModule
+ ],
providers: []
}).compileComponents();
-import { Component, TemplateRef, ViewChild } from '@angular/core';
+import { Component, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
import _ from 'lodash';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
enum TABS {
- 'gateways',
- 'subsystem',
- 'namespace'
+ gateways = 'gateways',
+ subsystem = 'subsystem',
+ namespace = 'namespace'
}
@Component({
styleUrls: ['./nvmeof-gateway.component.scss'],
standalone: false
})
-export class NvmeofGatewayComponent {
+export class NvmeofGatewayComponent implements OnInit {
selectedTab: TABS;
+ activeTab: TABS = TABS.gateways;
+
+ @ViewChild('statusTpl', { static: true })
+ statusTpl: TemplateRef<any>;
+ selection = new CdTableSelection();
+
+ constructor(public actionLabels: ActionLabelsI18n, private route: ActivatedRoute) {}
+
+ ngOnInit() {
+ this.route.queryParams.subscribe((params) => {
+ if (params['tab'] && Object.values(TABS).includes(params['tab'])) {
+ this.activeTab = params['tab'] as TABS;
+ }
+ });
+ }
onSelected(tab: TABS) {
this.selectedTab = tab;
+ this.activeTab = tab;
}
public get Tabs(): typeof TABS {
return TABS;
}
-
- @ViewChild('statusTpl', { static: true })
- statusTpl: TemplateRef<any>;
- selection = new CdTableSelection();
-
- constructor(public actionLabels: ActionLabelsI18n) {}
}
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { Icons } from '~/app/shared/enum/icons.enum';
import { Permission } from '~/app/shared/models/permissions';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { ActivatedRoute, Router } from '@angular/router';
import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service';
-import { Icons } from '~/app/shared/enum/icons.enum';
-
@Component({
selector: 'cd-nvmeof-initiators-form',
templateUrl: './nvmeof-initiators-form.component.html',
-<legend>
- <cd-help-text>
- An initiator (or host) is the client that connects to the NVMe-oF target to access NVMe storage.
- The NVMe/TCP protocol allows initiators, to send NVMe-oF commands to storage devices, which are known as targets.
- </cd-help-text>
-</legend>
-<cd-table [data]="initiators"
+<cd-alert-panel
+ *ngIf="hasAllHostsAllowed()"
+ type="info"
+ [showTitle]="false">
+ <div cdsStack="horizontal"
+ gap="2">
+ <div cdsStack="vertical"
+ gap="2">
+ <strong i18n>Host access: All hosts allowed</strong>
+ <p
+ class="cds-mb-0 cds-mt-1"
+ i18n
+ >
+ Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access.
+ </p>
+ </div>
+ <span
+ class="text-nowrap cds-ml-3 text-muted"
+ i18n
+ >
+ Edit host access
+ </span>
+ </div>
+</cd-alert-panel>
+
+<cd-table [data]="hasAllHostsAllowed() ? [] : initiators"
columnMode="flex"
(fetchData)="listInitiators()"
[columns]="initiatorColumns"
</cd-table-actions>
</div>
</cd-table>
-<ng-template #hostTpl
- let-value="data.value">
- <span *ngIf="value === '*'"
- i18n
- class="font-monospace">Any host allowed (*)</span>
- <span *ngIf="value !== '*'"
- class="font-monospace">{{value}}</span>
+<ng-template #dhchapTpl>
+ <div cdsStack="horizontal"
+ gap="4">
+ {{ authStatus !== authType.NO_AUTH ? 'Yes' : 'No' }}
+ </div>
</ng-template>
const mockInitiators = [
{
- nqn: '*'
+ nqn: '*',
+ dhchap_key: ''
}
];
+const mockSubsystem = {
+ nqn: 'nqn.2016-06.io.spdk:cnode1',
+ serial_number: '12345',
+ psk: ''
+};
+
class MockNvmeOfService {
getInitiators() {
return of(mockInitiators);
}
+ getSubsystem() {
+ return of(mockSubsystem);
+ }
}
class MockAuthStorageService {
getPermissions() {
- return { nvmeof: {} };
+ return { nvmeof: { read: true, create: true, delete: true } };
}
}
fixture = TestBed.createComponent(NvmeofInitiatorsListComponent);
component = fixture.componentInstance;
+ component.subsystemNQN = 'nqn.2016-06.io.spdk:cnode1';
+ component.group = 'group1';
component.ngOnInit();
fixture.detectChanges();
});
expect(component).toBeTruthy();
});
- it('should retrieve initiators', fakeAsync(() => {
+ it('should retrieve initiators and subsystem', fakeAsync(() => {
component.listInitiators();
+ component.getSubsystem();
tick();
expect(component.initiators).toEqual(mockInitiators);
+ expect(component.subsystem).toEqual(mockSubsystem);
+ expect(component.authStatus).toBe('No authentication');
+ }));
+
+ it('should update authStatus when initiator has dhchap_key', fakeAsync(() => {
+ const initiatorsWithKey = [{ nqn: 'nqn1', dhchap_key: 'key1' }];
+ spyOn(TestBed.inject(NvmeofService), 'getInitiators').and.returnValue(of(initiatorsWithKey));
+ component.listInitiators();
+ tick();
+ expect(component.authStatus).toBe('Unidirectional');
+ }));
+
+ it('should update authStatus when subsystem has psk', fakeAsync(() => {
+ const subsystemWithPsk = { ...mockSubsystem, psk: 'psk1' };
+ spyOn(TestBed.inject(NvmeofService), 'getSubsystem').and.returnValue(of(subsystemWithPsk));
+ component.getSubsystem();
+ tick();
+ expect(component.authStatus).toBe('Bi-directional');
}));
});
import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
-import { Router } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdTableAction } from '~/app/shared/models/cd-table-action';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { FinishedTask } from '~/app/shared/models/finished-task';
-import { NvmeofSubsystemInitiator } from '~/app/shared/models/nvmeof';
+import {
+ NvmeofSubsystem,
+ NvmeofSubsystemInitiator,
+ getSubsystemAuthStatus
+} from '~/app/shared/models/nvmeof';
import { Permission } from '~/app/shared/models/permissions';
+import { NvmeofSubsystemAuthType } from '~/app/shared/enum/nvmeof.enum';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
@Input()
group: string;
- @ViewChild('hostTpl', { static: true })
- hostTpl: TemplateRef<any>;
+ @ViewChild('dhchapTpl', { static: true })
+ dhchapTpl: TemplateRef<any>;
initiatorColumns: any;
tableActions: CdTableAction[];
selection = new CdTableSelection();
permission: Permission;
initiators: NvmeofSubsystemInitiator[] = [];
+ subsystem: NvmeofSubsystem;
+ authStatus: string;
+ authType = NvmeofSubsystemAuthType;
constructor(
public actionLabels: ActionLabelsI18n,
private nvmeofService: NvmeofService,
private modalService: ModalCdsService,
private router: Router,
- private taskWrapper: TaskWrapperService
+ private taskWrapper: TaskWrapperService,
+ private route: ActivatedRoute
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit() {
+ if (!this.subsystemNQN || !this.group) {
+ this.route.parent?.params.subscribe((params) => {
+ if (params['subsystem_nqn']) {
+ this.subsystemNQN = params['subsystem_nqn'];
+ }
+ this.fetchIfReady();
+ });
+ this.route.queryParams.subscribe((qp) => {
+ if (qp['group']) {
+ this.group = qp['group'];
+ }
+ this.fetchIfReady();
+ });
+ } else {
+ this.getSubsystem();
+ }
+
this.initiatorColumns = [
{
- name: $localize`Initiator`,
- prop: 'nqn',
- cellTemplate: this.hostTpl
+ name: $localize`Host NQN`,
+ prop: 'nqn'
+ },
+ {
+ name: $localize`DHCHAP key`,
+ prop: 'dhchap_key',
+ cellTemplate: this.dhchapTpl
}
];
this.tableActions = [
[BASE_URL, { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }],
{ queryParams: { group: this.group } }
),
- canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+ disable: () => this.hasAllHostsAllowed()
},
{
name: this.actionLabels.REMOVE,
];
}
+ private fetchIfReady() {
+ if (this.subsystemNQN && this.group) {
+ this.listInitiators();
+ this.getSubsystem();
+ }
+ }
+
getAllowAllHostIndex() {
return this.selection.selected.findIndex((selected) => selected.nqn === '*');
}
+ hasAllHostsAllowed(): boolean {
+ return this.initiators.some((initiator) => initiator.nqn === '*');
+ }
+
+ editHostAccess() {
+ this.router.navigate(
+ [BASE_URL, { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }],
+ { queryParams: { group: this.group } }
+ );
+ }
+
updateSelection(selection: CdTableSelection) {
this.selection = selection;
}
.getInitiators(this.subsystemNQN, this.group)
.subscribe((initiators: NvmeofSubsystemInitiator[]) => {
this.initiators = initiators;
+ this.updateAuthStatus();
});
}
+ getSubsystem() {
+ this.nvmeofService.getSubsystem(this.subsystemNQN, this.group).subscribe((subsystem: any) => {
+ this.subsystem = subsystem;
+ this.updateAuthStatus();
+ });
+ }
+
+ updateAuthStatus() {
+ if (this.subsystem && this.initiators) {
+ this.authStatus = getSubsystemAuthStatus(this.subsystem, this.initiators);
+ }
+ }
+
getSelectedNQNs() {
return this.selection.selected.map((selected) => selected.nqn);
}
-<legend>
- <cd-help-text>
- A listener defines the IP address and port on the gateway that is used to process NVMe/TCP admin and I/O commands to a subsystem.
- </cd-help-text>
-</legend>
+<cd-alert-panel *ngIf="listeners && listeners.length === 0"
+ type="error"
+ title="No listeners exists"
+ spacingClass="cds-mb-3"
+ i18n-title>
+ <ng-container i18n>Currently, there are no listeners available in the NVMe subsystem. Please check your configuration or try again later.</ng-container>
+</cd-alert-panel>
+
<cd-table [data]="listeners"
columnMode="flex"
(fetchData)="listListeners()"
import { Component, Input, OnInit } from '@angular/core';
-import { Router } from '@angular/router';
+import { ActivatedRoute, Router } from '@angular/router';
import { NvmeofService } from '~/app/shared/api/nvmeof.service';
import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
private authStorageService: AuthStorageService,
private taskWrapper: TaskWrapperService,
private nvmeofService: NvmeofService,
- private router: Router
+ private router: Router,
+ private route: ActivatedRoute
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit() {
+ // If inputs are not provided, try to get from route params (when used as routed component)
+ if (!this.subsystemNQN || !this.group) {
+ this.route.parent?.params.subscribe((params) => {
+ if (params['subsystem_nqn']) {
+ this.subsystemNQN = params['subsystem_nqn'];
+ }
+ if (this.subsystemNQN && this.group) {
+ this.listListeners();
+ }
+ });
+ this.route.queryParams.subscribe((qp) => {
+ if (qp['group']) {
+ this.group = qp['group'];
+ }
+ if (this.subsystemNQN && this.group) {
+ this.listListeners();
+ }
+ });
+ }
+
this.listenerColumns = [
{
- name: $localize`Host`,
+ name: $localize`Name`,
prop: 'host_name'
},
{
.getNamespace(this.subsystemNQN, this.nsid, this.group)
.subscribe((res: NvmeofSubsystemNamespace) => {
const convertedSize = this.dimlessBinaryPipe.transform(res.rbd_image_size).split(' ');
- this.currentBytes = res.rbd_image_size;
+ this.currentBytes =
+ typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size;
this.nsForm.get('pool').setValue(res.rbd_pool_name);
this.nsForm.get('unit').setValue(convertedSize[1]);
this.nsForm.get('image_size').setValue(convertedSize[0]);
--- /dev/null
+ <cd-table [data]="namespaces"
+ columnMode="flex"
+ (fetchData)="listNamespaces()"
+ [columns]="namespacesColumns"
+ selectionType="single"
+ (updateSelection)="updateSelection($event)"
+ emptyStateTitle="No namespaces created."
+ i18n-emptyStateTitle
+ emptyStateMessage="Namespaces are storage volumes mapped to subsystems for host access. Create a namespace to start provisioning storage within a subsystem."
+ i18n-emptyStateMessage>
+
+ <div class="table-actions">
+ <cd-table-actions [permission]="permission"
+ [selection]="selection"
+ class="btn-group"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
+ </cd-table>
--- /dev/null
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ActivatedRoute } from '@angular/router';
+import { of } from 'rxjs';
+
+import { NvmeofSubsystemNamespacesListComponent } from './nvmeof-subsystem-namespaces-list.component';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { SharedModule } from '~/app/shared/shared.module';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+
+describe('NvmeofSubsystemNamespacesListComponent', () => {
+ let component: NvmeofSubsystemNamespacesListComponent;
+ let fixture: ComponentFixture<NvmeofSubsystemNamespacesListComponent>;
+ let nvmeofService: NvmeofService;
+
+ const mockNamespaces = [
+ {
+ nsid: 1,
+ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1',
+ rbd_image_name: 'image1',
+ rbd_pool_name: 'pool1',
+ rbd_image_size: 1024,
+ block_size: 512,
+ rw_ios_per_second: 100
+ },
+ {
+ nsid: 2,
+ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode2', // Different subsystem
+ rbd_image_name: 'image2',
+ rbd_pool_name: 'pool1',
+ rbd_image_size: 1024,
+ block_size: 512,
+ rw_ios_per_second: 100
+ }
+ ];
+
+ class MockAuthStorageService {
+ getPermissions() {
+ return { nvmeof: {} };
+ }
+ }
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofSubsystemNamespacesListComponent],
+ imports: [HttpClientTestingModule, RouterTestingModule, SharedModule],
+ providers: [
+ {
+ provide: ActivatedRoute,
+ useValue: {
+ parent: {
+ params: of({ subsystem_nqn: 'nqn.2016-06.io.spdk:cnode1', group: 'group1' })
+ },
+ queryParams: of({ group: 'group1' })
+ }
+ },
+ {
+ provide: NvmeofService,
+ useValue: {
+ listNamespaces: jest.fn().mockReturnValue(of(mockNamespaces))
+ }
+ },
+ { provide: AuthStorageService, useClass: MockAuthStorageService }
+ ]
+ }).compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(NvmeofSubsystemNamespacesListComponent);
+ component = fixture.componentInstance;
+ nvmeofService = TestBed.inject(NvmeofService);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).not.toBeNull();
+ expect(component).not.toBeUndefined();
+ });
+
+ it('should list namespaces filtered by subsystem', fakeAsync(() => {
+ component.ngOnInit(); // Trigger ngOnInit
+ tick(); // wait for ngOnInit subscription
+ expect(nvmeofService.listNamespaces).toHaveBeenCalledWith(
+ 'group1',
+ 'nqn.2016-06.io.spdk:cnode1'
+ );
+ expect(component.namespaces.length).toEqual(2);
+ expect(component.namespaces[0].nsid).toEqual(1);
+ }));
+});
--- /dev/null
+import { Component, OnDestroy, OnInit } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
+import { Permission } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { IopsPipe } from '~/app/shared/pipes/iops.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { combineLatest, Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+const BASE_URL = 'block/nvmeof/subsystems';
+
+@Component({
+ selector: 'cd-nvmeof-subsystem-namespaces-list',
+ templateUrl: './nvmeof-subsystem-namespaces-list.component.html',
+ styleUrls: ['./nvmeof-subsystem-namespaces-list.component.scss'],
+ standalone: false
+})
+export class NvmeofSubsystemNamespacesListComponent implements OnInit, OnDestroy {
+ subsystemNQN: string;
+ group: string;
+ namespacesColumns: any;
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ permission: Permission;
+ namespaces: NvmeofSubsystemNamespace[] = [];
+
+ private destroy$ = new Subject<void>();
+
+ constructor(
+ // ... constructor stays mostly same
+ public actionLabels: ActionLabelsI18n,
+ private router: Router,
+ private modalService: ModalCdsService,
+ private authStorageService: AuthStorageService,
+ private taskWrapper: TaskWrapperService,
+ private nvmeofService: NvmeofService,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private iopsPipe: IopsPipe,
+ private route: ActivatedRoute
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ }
+
+ ngOnInit() {
+ combineLatest([this.route.parent?.params, this.route.queryParams])
+ .pipe(takeUntil(this.destroy$))
+ .subscribe(([params, qp]) => {
+ this.subsystemNQN = params['subsystem_nqn'];
+ this.group = qp['group'];
+ if (this.subsystemNQN && this.group) {
+ this.listNamespaces();
+ }
+ });
+
+ this.setupColumns();
+ this.setupTableActions();
+ }
+
+ setupColumns() {
+ this.namespacesColumns = [
+ {
+ name: $localize`Namespace ID`,
+ prop: 'nsid'
+ },
+ {
+ name: $localize`Pool`,
+ prop: 'rbd_pool_name',
+ flexGrow: 2
+ },
+ {
+ name: $localize`Image`,
+ prop: 'rbd_image_name',
+ flexGrow: 3
+ },
+ {
+ name: $localize`Image Size`,
+ prop: 'rbd_image_size',
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ name: $localize`Block Size`,
+ prop: 'block_size',
+ pipe: this.dimlessBinaryPipe
+ },
+ {
+ name: $localize`IOPS`,
+ prop: 'rw_ios_per_second',
+ sortable: false,
+ pipe: this.iopsPipe,
+ flexGrow: 1.5
+ }
+ ];
+ }
+
+ setupTableActions() {
+ this.tableActions = [
+ {
+ name: this.actionLabels.CREATE,
+ permission: 'create',
+ icon: Icons.add,
+ click: () =>
+ this.router.navigate(
+ [BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }],
+ { queryParams: { group: this.group } }
+ ),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ },
+ {
+ name: this.actionLabels.EDIT,
+ permission: 'update',
+ icon: Icons.edit,
+ click: () =>
+ this.router.navigate(
+ [
+ BASE_URL,
+ {
+ outlets: {
+ modal: [
+ URLVerbs.EDIT,
+ this.subsystemNQN,
+ 'namespace',
+ this.selection.first().nsid
+ ]
+ }
+ }
+ ],
+ { queryParams: { group: this.group } }
+ )
+ },
+ {
+ name: this.actionLabels.DELETE,
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteNamespaceModal()
+ }
+ ];
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ listNamespaces() {
+ if (this.group) {
+ this.nvmeofService
+ .listNamespaces(this.group, this.subsystemNQN)
+ .pipe(takeUntil(this.destroy$))
+ .subscribe((res: NvmeofSubsystemNamespace[]) => {
+ this.namespaces = res || [];
+ });
+ } else {
+ this.namespaces = [];
+ }
+ }
+
+ deleteNamespaceModal() {
+ const namespace = this.selection.first();
+ this.modalService.show(DeleteConfirmationModalComponent, {
+ itemDescription: 'Namespace',
+ itemNames: [namespace.nsid],
+ actionDescription: 'delete',
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('nvmeof/namespace/delete', {
+ nqn: this.subsystemNQN,
+ nsid: namespace.nsid
+ }),
+ call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group)
+ })
+ });
+ }
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
+}
<a ngbNavLink
i18n>Listeners</a>
<ng-template ngbNavContent>
- <cd-nvmeof-listeners-list [subsystemNQN]="subsystemNQN"
- [group]="group">
- </cd-nvmeof-listeners-list>
+ <cd-nvmeof-listeners-list
+ [subsystemNQN]="subsystemNQN"
+ [group]="group">
+ </cd-nvmeof-listeners-list>
</ng-template>
</ng-container>
<ng-container ngbNavItem="namespaces">
<a ngbNavLink
i18n>Namespaces</a>
<ng-template ngbNavContent>
- <cd-nvmeof-namespaces-list [subsystemNQN]="subsystemNQN"
- [group]="group">
- </cd-nvmeof-namespaces-list>
+ <cd-nvmeof-namespaces-list
+ [subsystemNQN]="subsystemNQN"
+ [group]="group">
+ </cd-nvmeof-namespaces-list>
</ng-template>
</ng-container>
<ng-container ngbNavItem="initiators">
type StepResult = { step: string; success: boolean; error?: string };
-const PAGE_URL = 'block/nvmeof/subsystems';
-
@Component({
selector: 'cd-nvmeof-subsystems-form',
templateUrl: './nvmeof-subsystems-form.component.html',
title: string = $localize`Create Subsystem`;
description: string = $localize`Subsytems define how hosts connect to NVMe namespaces and ensure secure access to storage.`;
isSubmitLoading: boolean = false;
+ private lastCreatedNqn: string;
@ViewChild(TearsheetComponent) tearsheet!: TearsheetComponent;
}
onSubmit(payload: SubsystemPayload) {
this.isSubmitLoading = true;
+ this.lastCreatedNqn = payload.nqn;
const stepResults: StepResult[] = [];
const initiatorRequest: InitiatorRequest = {
host_nqn: payload.hostType === HOST_TYPE.ALL ? '*' : payload.addedHosts.join(','),
errorMsg
);
this.isSubmitLoading = false;
- this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
+ this.router.navigate(['block/nvmeof/gateways'], {
+ queryParams: { group: this.group, tab: 'subsystem' }
+ });
}
});
}
: $localize`Subsystem created`;
this.notificationService.show(type, title, sanitizedHtml);
- this.router.navigate([PAGE_URL, { outlets: { modal: null } }]);
+ this.router.navigate(['block/nvmeof/gateways'], {
+ queryParams: {
+ group: this.group,
+ tab: 'subsystem',
+ nqn: stepResults[0]?.success ? this.lastCreatedNqn : null
+ }
+ });
}
}
</cd-table>
</ng-container>
+<ng-template #customTableItemTemplate
+ let-value="data.value"
+ let-row="data.row">
+ <a cdsLink
+ [routerLink]="['/block/nvmeof/subsystems', value]"
+ [queryParams]="{ group: row.gw_group }"
+ (click)="$event.stopPropagation()">
+ {{ value }}
+ </a>
+</ng-template>
+
<ng-template #authenticationTpl
let-row="data.row">
<div [cdsStack]="'horizontal'"
-import { Component, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import {
+ Component,
+ OnDestroy,
+ OnInit,
+ TemplateRef,
+ ViewChild,
+ ChangeDetectorRef
+} from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
import { FinishedTask } from '~/app/shared/models/finished-task';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofService, GroupsComboboxItem } from '~/app/shared/api/nvmeof.service';
-import { NotificationService } from '~/app/shared/services/notification.service';
-import { NotificationType } from '~/app/shared/enum/notification-type.enum';
import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { CephServiceSpec } from '~/app/shared/models/service.interface';
import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
-import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
+import { catchError, map, switchMap, takeUntil, tap } from 'rxjs/operators';
import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { TableComponent } from '~/app/shared/datatable/table/table.component';
const BASE_URL = 'block/nvmeof/subsystems';
const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
@ViewChild('deleteTpl', { static: true })
deleteTpl: TemplateRef<any>;
+ @ViewChild('customTableItemTemplate', { static: true })
+ customTableItemTemplate: TemplateRef<any>;
+
+ @ViewChild('table') table: TableComponent;
+
+ subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = [];
+ pendingNqn: string = null;
subsystemsColumns: any;
permissions: Permissions;
selection = new CdTableSelection();
private route: ActivatedRoute,
private modalService: ModalCdsService,
private taskWrapper: TaskWrapperService,
- private notificationService: NotificationService
+ private cdRef: ChangeDetectorRef
) {
super();
this.permissions = this.authStorageService.getPermissions();
ngOnInit() {
this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ if (params?.['nqn']) this.pendingNqn = params['nqn'];
if (params?.['group']) this.onGroupSelection({ content: params?.['group'] });
});
this.setGatewayGroups();
{
name: $localize`Subsystem NQN`,
prop: 'nqn',
- flexGrow: 2
+ flexGrow: 2,
+ cellTemplate: this.customTableItemTemplate
},
{
name: $localize`Gateway group`,
return forkJoin(subs.map((sub) => this.enrichSubsystemWithInitiators(sub)));
}),
catchError((error) => {
- this.notificationService.show(
- NotificationType.error,
- $localize`Unable to fetch Gateway group`,
- $localize`Gateway group does not exist`
- );
this.handleError(error);
return of([]);
})
);
}),
+ tap((subs) => {
+ this.subsystems = subs;
+ this.expandPendingSubsystem();
+ }),
takeUntil(this.destroy$)
);
}
}
updateGroupSelectionState() {
- if (!this.group && this.gwGroups.length) {
- this.onGroupSelection(this.gwGroups[0]);
+ if (this.gwGroups.length) {
+ if (!this.group) {
+ this.onGroupSelection(this.gwGroups[0]);
+ } else {
+ this.gwGroups = this.gwGroups.map((g) => ({
+ ...g,
+ selected: g.content === this.group
+ }));
+ }
this.gwGroupsEmpty = false;
this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER;
} else {
this.context?.error?.(error);
}
+ private expandPendingSubsystem() {
+ if (!this.pendingNqn) return;
+ const match = this.subsystems.find((s) => s.nqn === this.pendingNqn);
+ if (match && this.table) {
+ setTimeout(() => {
+ this.table.expanded = match;
+ this.table.toggleExpandRow();
+ this.cdRef.detectChanges();
+ });
+ }
+ this.pendingNqn = null;
+ }
+
private enrichSubsystemWithInitiators(sub: NvmeofSubsystem) {
return this.nvmeofService.getInitiators(sub.nqn, this.group).pipe(
catchError(() => of([])),
@for (item of items; track item.label) {
<cds-sidenav-item
[route]="item.route"
+ [routeExtras]="item.routeExtras"
[useRouter]="true"
[routerLinkActiveOptions]="item.routerLinkActiveOptions || { exact: false }">
<span class="cds--type-heading-compact-01">{{ item.label }}</span>
export interface SidebarItem {
label: string;
route: string[];
+ routeExtras?: any;
routerLinkActiveOptions?: { exact: boolean };
}
rbd_image_name: string;
rbd_pool_name: string;
load_balancing_group: number;
- rbd_image_size: number;
+ rbd_image_size: number | string;
block_size: number;
- rw_ios_per_second: number;
- rw_mbytes_per_second: number;
- r_mbytes_per_second: number;
- w_mbytes_per_second: number;
+ rw_ios_per_second: number | string;
+ rw_mbytes_per_second: number | string;
+ r_mbytes_per_second: number | string;
+ w_mbytes_per_second: number | string;
+ ns_subsystem_nqn?: string; // Field from JSON
+ subsystem_nqn?: string; // Keep for compatibility if needed, but JSON has ns_subsystem_nqn
}
export interface NvmeofGatewayGroup extends CephServiceSpec {
margin-top: layout.$spacing-03;
}
+.cds-mt-1 {
+ margin-top: layout.$spacing-01;
+}
+
.cds-mt-5 {
margin-top: layout.$spacing-05;
}