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',
standalone: false
})
export class NvmeofInitiatorsFormComponent implements OnInit {
+ icons = Icons;
permission: Permission;
initiatorForm: CdFormGroup;
action: string;
-<legend>
- <cd-help-text>
- An NVMe namespace is a quantity of non-volatile storage that can be formatted into logical blocks and presented to a host as a standard block device.
- </cd-help-text>
-</legend>
-<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 cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+<div cdsCol
+ [columnNumbers]="{sm: 4, md: 8}">
+ <div class="pb-3 form-item"
+ cdsRow>
+ <cds-combo-box
+ type="single"
+ label="Selected Gateway Group"
+ i18n-label
+ [placeholder]="gwGroupPlaceholder"
+ [items]="gwGroups"
+ (selected)="onGroupSelection($event)"
+ (clear)="onGroupClear()"
+ [disabled]="gwGroupsEmpty">
+ <cds-dropdown-list></cds-dropdown-list>
+ </cds-combo-box>
</div>
-</cd-table>
+</div>
+</div>
+
+<ng-container *ngIf="namespaces$ | async as namespaces">
+ <cd-table [data]="namespaces"
+ columnMode="flex"
+ (fetchData)="fetchData()"
+ [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>
+</ng-container>
-import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientModule } from '@angular/common/http';
+import { CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import { of } from 'rxjs';
import { RouterTestingModule } from '@angular/router/testing';
import { SharedModule } from '~/app/shared/shared.module';
import { NvmeofService } from '../../../shared/api/nvmeof.service';
import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
-import { ModalService } from '~/app/shared/services/modal.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list.component';
];
class MockNvmeOfService {
- listNamespaces() {
- return of(mockNamespaces);
+ listGatewayGroups() {
+ return of([[{ id: 'g1' }]]);
+ }
+
+ formatGwGroupsList(_response: any) {
+ return [{ content: 'g1', selected: false }];
+ }
+
+ listNamespaces(_group?: string) {
+ return of({ namespaces: mockNamespaces });
}
}
}
}
-class MockModalService {}
+class MockModalCdsService {
+ show = jasmine.createSpy('show');
+}
class MockTaskWrapperService {}
let component: NvmeofNamespacesListComponent;
let fixture: ComponentFixture<NvmeofNamespacesListComponent>;
+ let modalService: MockModalCdsService;
+
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [NvmeofNamespacesListComponent, NvmeofSubsystemsDetailsComponent],
providers: [
{ provide: NvmeofService, useClass: MockNvmeOfService },
{ provide: AuthStorageService, useClass: MockAuthStorageService },
- { provide: ModalService, useClass: MockModalService },
+ { provide: ModalCdsService, useClass: MockModalCdsService },
{ provide: TaskWrapperService, useClass: MockTaskWrapperService }
- ]
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents();
fixture = TestBed.createComponent(NvmeofNamespacesListComponent);
component.ngOnInit();
component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436';
fixture.detectChanges();
+ modalService = TestBed.inject(ModalCdsService) as any;
});
it('should create', () => {
expect(component).toBeTruthy();
});
- it('should retrieve namespaces', fakeAsync(() => {
+ it('should retrieve namespaces', (done) => {
+ component.group = 'g1';
+ component.namespaces$.subscribe((namespaces) => {
+ expect(namespaces).toEqual(mockNamespaces);
+ done();
+ });
component.listNamespaces();
- tick();
- expect(component.namespaces).toEqual(mockNamespaces);
- }));
+ });
+
+ it('should open delete modal with correct data', () => {
+ // Mock selection
+ const namespace = {
+ nsid: 1,
+ ns_subsystem_nqn: 'nqn.2001-07.com.ceph:1721040751436'
+ };
+ component.selection = {
+ first: () => namespace
+ } as any;
+ component.deleteNamespaceModal();
+ expect(modalService.show).toHaveBeenCalled();
+ const args = modalService.show.calls.mostRecent().args[1];
+ expect(args.itemNames).toEqual([1]);
+ expect(args.itemDescription).toBeDefined();
+ expect(typeof args.submitActionObservable).toBe('function');
+ });
});
-import { Component, Input, OnInit } from '@angular/core';
-import { Router } from '@angular/router';
-import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { Component, Input, OnDestroy, OnInit, TemplateRef, ViewChild } from '@angular/core';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService, GroupsComboboxItem } 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 { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
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 { CephServiceSpec } from '~/app/shared/models/service.interface';
import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
-import { IopsPipe } from '~/app/shared/pipes/iops.pipe';
-import { MbpersecondPipe } from '~/app/shared/pipes/mbpersecond.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 { BehaviorSubject, Observable, of, Subject } from 'rxjs';
+import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
const BASE_URL = 'block/nvmeof/subsystems';
+const DEFAULT_PLACEHOLDER = $localize`Enter group name`;
@Component({
selector: 'cd-nvmeof-namespaces-list',
styleUrls: ['./nvmeof-namespaces-list.component.scss'],
standalone: false
})
-export class NvmeofNamespacesListComponent implements OnInit {
+export class NvmeofNamespacesListComponent implements OnInit, OnDestroy {
@Input()
subsystemNQN: string;
@Input()
group: string;
-
+ @ViewChild('deleteTpl', { static: true })
+ deleteTpl: TemplateRef<any>;
namespacesColumns: any;
tableActions: CdTableAction[];
selection = new CdTableSelection();
permission: Permission;
- namespaces: NvmeofSubsystemNamespace[];
+ namespaces$: Observable<NvmeofSubsystemNamespace[]>;
+ private namespaceSubject = new BehaviorSubject<void>(undefined);
+
+ // Gateway group dropdown properties
+ gwGroups: GroupsComboboxItem[] = [];
+ gwGroupsEmpty: boolean = false;
+ gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER;
+
+ private destroy$ = new Subject<void>();
constructor(
public actionLabels: ActionLabelsI18n,
private router: Router,
+ private route: ActivatedRoute,
private modalService: ModalCdsService,
private authStorageService: AuthStorageService,
private taskWrapper: TaskWrapperService,
private nvmeofService: NvmeofService,
- private dimlessBinaryPipe: DimlessBinaryPipe,
- private mbPerSecondPipe: MbpersecondPipe,
- private iopsPipe: IopsPipe
+ private dimlessBinaryPipe: DimlessBinaryPipe
) {
this.permission = this.authStorageService.getPermissions().nvmeof;
}
ngOnInit() {
+ this.route.queryParams.pipe(takeUntil(this.destroy$)).subscribe((params) => {
+ if (params?.['group']) this.onGroupSelection({ content: params?.['group'] });
+ });
+ this.setGatewayGroups();
this.namespacesColumns = [
{
- name: $localize`ID`,
+ name: $localize`Namespace ID`,
prop: 'nsid'
},
{
- name: $localize`Bdev Name`,
- prop: 'bdev_name'
- },
- {
- name: $localize`Pool `,
- prop: 'rbd_pool_name',
- flexGrow: 2
- },
- {
- name: $localize`Image`,
- prop: 'rbd_image_name',
- flexGrow: 3
- },
- {
- name: $localize`Image Size`,
+ name: $localize`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
- },
- {
- name: $localize`R/W Throughput`,
- prop: 'rw_mbytes_per_second',
- sortable: false,
- pipe: this.mbPerSecondPipe,
- flexGrow: 1.5
- },
- {
- name: $localize`Read Throughput`,
- prop: 'r_mbytes_per_second',
- sortable: false,
- pipe: this.mbPerSecondPipe,
- flexGrow: 1.5
+ name: $localize`Pool`,
+ prop: 'rbd_pool_name'
},
{
- name: $localize`Write Throughput`,
- prop: 'w_mbytes_per_second',
- sortable: false,
- pipe: this.mbPerSecondPipe,
- flexGrow: 1.5
+ name: $localize`Image`,
+ prop: 'rbd_image_name'
},
{
- name: $localize`Load Balancing Group`,
- prop: 'load_balancing_group',
- flexGrow: 1.5
+ name: $localize`Subsystem`,
+ prop: 'ns_subsystem_nqn'
}
];
this.tableActions = [
[BASE_URL, { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }],
{ queryParams: { group: this.group } }
),
- canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection,
+ disable: () => !this.group
},
{
name: this.actionLabels.EDIT,
click: () => this.deleteNamespaceModal()
}
];
+
+ this.namespaces$ = this.namespaceSubject.pipe(
+ switchMap(() => {
+ if (!this.group) {
+ return of([]);
+ }
+ return this.nvmeofService.listNamespaces(this.group).pipe(
+ map((res: NvmeofSubsystemNamespace[] | { namespaces: NvmeofSubsystemNamespace[] }) => {
+ return Array.isArray(res) ? res : res.namespaces || [];
+ }),
+ catchError(() => of([]))
+ );
+ }),
+ takeUntil(this.destroy$)
+ );
}
updateSelection(selection: CdTableSelection) {
}
listNamespaces() {
- this.nvmeofService.listNamespaces(this.group).subscribe((res: NvmeofSubsystemNamespace[]) => {
- this.namespaces = res;
- });
+ this.namespaceSubject.next();
+ }
+
+ fetchData() {
+ this.namespaceSubject.next();
+ }
+
+ // Gateway groups methods
+ onGroupSelection(selected: GroupsComboboxItem) {
+ selected.selected = true;
+ this.group = selected.content;
+ this.listNamespaces();
+ }
+
+ onGroupClear() {
+ this.group = null;
+ this.listNamespaces();
+ }
+
+ setGatewayGroups() {
+ this.nvmeofService
+ .listGatewayGroups()
+ .pipe(takeUntil(this.destroy$))
+ .subscribe({
+ next: (response: CephServiceSpec[][]) => this.handleGatewayGroupsSuccess(response),
+ error: (error) => this.handleGatewayGroupsError(error)
+ });
+ }
+
+ handleGatewayGroupsSuccess(response: CephServiceSpec[][]) {
+ if (response?.[0]?.length) {
+ this.gwGroups = this.nvmeofService.formatGwGroupsList(response);
+ } else {
+ this.gwGroups = [];
+ }
+ this.updateGroupSelectionState();
+ }
+
+ updateGroupSelectionState() {
+ if (!this.group && this.gwGroups.length) {
+ this.onGroupSelection(this.gwGroups[0]);
+ this.gwGroupsEmpty = false;
+ this.gwGroupPlaceholder = DEFAULT_PLACEHOLDER;
+ } else if (!this.gwGroups.length) {
+ this.gwGroupsEmpty = true;
+ this.gwGroupPlaceholder = $localize`No groups available`;
+ }
+ }
+
+ handleGatewayGroupsError(error: any) {
+ this.gwGroups = [];
+ this.gwGroupsEmpty = true;
+ this.gwGroupPlaceholder = $localize`Unable to fetch Gateway groups`;
+ if (error?.preventDefault) {
+ error?.preventDefault?.();
+ }
}
deleteNamespaceModal() {
const namespace = this.selection.first();
+ const subsystemNqn = namespace.ns_subsystem_nqn;
this.modalService.show(DeleteConfirmationModalComponent, {
- itemDescription: 'Namespace',
+ itemDescription: $localize`Namespace`,
+ impact: DeletionImpact.high,
+ bodyTemplate: this.deleteTpl,
itemNames: [namespace.nsid],
actionDescription: 'delete',
+ bodyContext: {
+ deletionMessage: $localize`Deleting the namespace <strong>${namespace.nsid}</strong> will permanently remove all resources, services, and configurations within it. This action cannot be undone.`
+ },
submitActionObservable: () =>
this.taskWrapper.wrapTaskAroundCall({
task: new FinishedTask('nvmeof/namespace/delete', {
- nqn: this.subsystemNQN,
+ nqn: subsystemNqn,
nsid: namespace.nsid
}),
- call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid, this.group)
+ call: this.nvmeofService.deleteNamespace(subsystemNqn, namespace.nsid, this.group)
})
});
}
+
+ ngOnDestroy() {
+ this.destroy$.next();
+ this.destroy$.complete();
+ }
}
-<div class="pb-3"
- cdsCol
- [columnNumbers]="{md: 6}">
- <cds-combo-box
- type="single"
- label="Selected Gateway Group"
- i18n-label
- [placeholder]="gwGroupPlaceholder"
- [items]="gwGroups"
- (selected)="onGroupSelection($event)"
- (clear)="onGroupClear()"
- [disabled]="gwGroupsEmpty">
- <cds-dropdown-list></cds-dropdown-list>
- </cds-combo-box>
+<div cdsGrid
+ [useCssGrid]="true"
+ [narrow]="true"
+ [fullWidth]="true">
+<div cdsCol
+ [columnNumbers]="{sm: 4, md: 8}">
+ <div class="pb-3 form-item"
+ cdsRow>
+ <cds-combo-box
+ type="single"
+ label="Selected Gateway Group"
+ i18n-label
+ [placeholder]="gwGroupPlaceholder"
+ [items]="gwGroups"
+ (selected)="onGroupSelection($event)"
+ (clear)="onGroupClear()"
+ [disabled]="gwGroupsEmpty">
+ <cds-dropdown-list></cds-dropdown-list>
+ </cds-combo-box>
+ </div>
</div>
+</div>
+<ng-container *ngIf="subsystems$ | async as subsystems">
+ <cd-table #table
+ [data]="subsystems"
+ [columns]="subsystemsColumns"
+ columnMode="flex"
+ selectionType="single"
+ [hasDetails]="true"
+ (setExpandedRow)="setExpandedRow($event)"
+ (updateSelection)="updateSelection($event)"
+ (fetchData)="fetchData()"
+ emptyStateTitle="No subsystems created"
+ i18n-emptyStateTitle
+ emptyStateMessage="Subsystems group NVMe namespaces and manage host access. Create a subsystem to start mapping NVMe volumes to hosts."
+ i18n-emptyStateMessage>
-<cd-table #table
- [data]="subsystems"
- [columns]="subsystemsColumns"
- columnMode="flex"
- selectionType="single"
- [hasDetails]="true"
- (setExpandedRow)="setExpandedRow($event)"
- (updateSelection)="updateSelection($event)"
- emptyStateTitle="No subsystems created"
- i18n-emptyStateTitle
- emptyStateMessage="Subsystems group NVMe namespaces and manage host access. Create a subsystem to start mapping NVMe volumes to hosts."
- i18n-emptyStateMessage>
-
- <div class="table-actions">
- <cd-table-actions [permission]="permissions.nvmeof"
- [selection]="selection"
- class="btn-group"
- [tableActions]="tableActions">
- </cd-table-actions>
- </div>
+ <div class="table-actions">
+ <cd-table-actions [permission]="permissions.nvmeof"
+ [selection]="selection"
+ class="btn-group"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ </div>
- <cd-nvmeof-subsystems-details *cdTableDetail
- [selection]="expandedRow"
- [permissions]="permissions"
- [group]="expandedRow?.gw_group">
- </cd-nvmeof-subsystems-details>
-</cd-table>
+ <cd-nvmeof-subsystems-details *cdTableDetail
+ [selection]="expandedRow"
+ [permissions]="permissions"
+ [group]="expandedRow?.gw_group">
+ </cd-nvmeof-subsystems-details>
+ </cd-table>
+</ng-container>
<ng-template #authenticationTpl
let-row="data.row">
-import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
import { HttpClientModule } from '@angular/common/http';
import { of } from 'rxjs';
import { RouterTestingModule } from '@angular/router/testing';
expect(component).toBeTruthy();
});
- it('should retrieve subsystems', fakeAsync(() => {
- component.getSubsystems();
- tick();
+ it('should retrieve subsystems', (done) => {
const expected = mockSubsystems.map((s) => ({
...s,
gw_group: component.group,
auth: 'No authentication',
initiator_count: 0
}));
- expect(component.subsystems).toEqual(expected);
- }));
+ component.subsystems$.subscribe((subsystems) => {
+ expect(subsystems).toEqual(expected);
+ done();
+ });
+ component.getSubsystems();
+ });
it('should load gateway groups correctly', () => {
expect(component.gwGroups.length).toBe(2);
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 { forkJoin, of, Subject } from 'rxjs';
+import { BehaviorSubject, forkJoin, Observable, of, Subject } from 'rxjs';
import { catchError, map, switchMap, takeUntil } from 'rxjs/operators';
import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
@ViewChild('deleteTpl', { static: true })
deleteTpl: TemplateRef<any>;
- subsystems: (NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[] = [];
subsystemsColumns: any;
permissions: Permissions;
selection = new CdTableSelection();
gwGroupsEmpty: boolean = false;
gwGroupPlaceholder: string = DEFAULT_PLACEHOLDER;
authType = NvmeofSubsystemAuthType;
+ subsystems$: Observable<(NvmeofSubsystem & { gw_group?: string; initiator_count?: number })[]>;
+ private subsystemSubject = new BehaviorSubject<void>(undefined);
private destroy$ = new Subject<void>();
click: () => this.deleteSubsystemModal()
}
];
- }
-
- updateSelection(selection: CdTableSelection) {
- this.selection = selection;
- }
- getSubsystems() {
- if (this.group) {
- this.nvmeofService
- .listSubsystems(this.group)
- .pipe(
+ this.subsystems$ = this.subsystemSubject.pipe(
+ switchMap(() => {
+ if (!this.group) {
+ return of([]);
+ }
+ return this.nvmeofService.listSubsystems(this.group).pipe(
switchMap((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => {
const subs = Array.isArray(subsystems) ? subsystems : [subsystems];
if (subs.length === 0) return of([]);
-
return forkJoin(subs.map((sub) => this.enrichSubsystemWithInitiators(sub)));
- })
- )
- .pipe(takeUntil(this.destroy$))
- .subscribe({
- next: (subsystems: NvmeofSubsystem[]) => {
- this.subsystems = subsystems;
- },
- error: (error) => {
- this.subsystems = [];
+ }),
+ catchError((error) => {
this.notificationService.show(
NotificationType.error,
$localize`Unable to fetch Gateway group`,
$localize`Gateway group does not exist`
);
this.handleError(error);
- }
- });
- } else {
- this.subsystems = [];
- }
+ return of([]);
+ })
+ );
+ }),
+ takeUntil(this.destroy$)
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ getSubsystems() {
+ this.subsystemSubject.next();
+ }
+
+ fetchData() {
+ this.subsystemSubject.next();
}
deleteSubsystemModal() {