import { NvmeofSubsystemsFormComponent } from './nvmeof-subsystems-form/nvmeof-subsystems-form.component';
import { NvmeofListenersFormComponent } from './nvmeof-listeners-form/nvmeof-listeners-form.component';
import { NvmeofListenersListComponent } from './nvmeof-listeners-list/nvmeof-listeners-list.component';
+import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list/nvmeof-namespaces-list.component';
+import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form/nvmeof-namespaces-form.component';
@NgModule({
imports: [
NvmeofTabsComponent,
NvmeofSubsystemsFormComponent,
NvmeofListenersFormComponent,
- NvmeofListenersListComponent
+ NvmeofListenersListComponent,
+ NvmeofNamespacesListComponent,
+ NvmeofNamespacesFormComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
component: NvmeofSubsystemsComponent,
data: { breadcrumbs: 'Subsystems' },
children: [
+ // subsystems
{ path: '', component: NvmeofSubsystemsComponent },
{
path: URLVerbs.CREATE,
outlet: 'modal'
},
{
- path: `${URLVerbs.EDIT}/:subsystem_nqn`,
+ path: `${URLVerbs.EDIT}/:subsystem_nqn/:max_ns`,
component: NvmeofSubsystemsFormComponent,
outlet: 'modal'
},
+ // listeners
{
path: `${URLVerbs.CREATE}/:subsystem_nqn/listener`,
- component: NvmeofListenersFormComponent,
+ component: NvmeofListenersFormComponent
+ },
+ // namespaces
+ {
+ path: `${URLVerbs.CREATE}/:subsystem_nqn/namespace`,
+ component: NvmeofNamespacesFormComponent,
+ outlet: 'modal'
+ },
+ {
+ path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
+ component: NvmeofNamespacesFormComponent,
outlet: 'modal'
}
]
--- /dev/null
+<cd-modal [pageURL]="pageURL"
+ [modalRef]="activeModal">
+ <span class="modal-title"
+ i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+ <ng-container class="modal-content">
+ <form name="nsForm"
+ #formDir="ngForm"
+ [formGroup]="nsForm"
+ novalidate>
+ <div class="modal-body">
+ <!-- Block Pool -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="pool">
+ <span [ngClass]="{'required': !edit}"
+ i18n>Pool</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input *ngIf="edit"
+ name="pool"
+ class="form-control"
+ type="text"
+ formControlName="pool">
+ <select *ngIf="!edit"
+ id="pool"
+ name="pool"
+ class="form-select"
+ formControlName="pool">
+ <option *ngIf="rbdPools === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="rbdPools && rbdPools.length === 0"
+ [ngValue]="null"
+ i18n>-- No block pools available --</option>
+ <option *ngIf="rbdPools && rbdPools.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a pool --</option>
+ <option *ngFor="let pool of rbdPools"
+ [value]="pool.pool_name">{{ pool.pool_name }}</option>
+ </select>
+ <cd-help-text i18n>
+ A RBD application-enabled pool where the image will be created.
+ </cd-help-text>
+ <span class="invalid-feedback"
+ *ngIf="nsForm.showError('pool', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- Image Name -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="image">
+ <span [ngClass]="{'required': !edit}"
+ i18n>Image Name</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input name="image"
+ class="form-control"
+ type="text"
+ formControlName="image">
+ <span class="invalid-feedback"
+ *ngIf="nsForm.showError('image', formDir, 'required')">
+ <ng-container i18n>This field is required.</ng-container>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="nsForm.showError('image', formDir, 'pattern')">
+ <ng-container i18n>'/' and '@' are not allowed.</ng-container>
+ </span>
+ </div>
+ </div>
+ <!-- Image Size -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="image_size">
+ <span [ngClass]="{'required': edit}"
+ i18n>Image Size</span>
+ </label>
+ <div class="cd-col-form-input">
+ <div class="input-group">
+ <input id="size"
+ class="form-control"
+ type="text"
+ name="image_size"
+ formControlName="image_size">
+ <select id="unit"
+ name="unit"
+ class="form-input form-select"
+ formControlName="unit">
+ <option *ngFor="let u of units"
+ [value]="u"
+ i18n>{{ u }}</option>
+ </select>
+ <span class="invalid-feedback"
+ *ngIf="nsForm.showError('image_size', formDir, 'pattern')">
+ <ng-container i18n>Enter a positive integer.</ng-container>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="edit && nsForm.showError('image_size', formDir, 'required')">
+ <ng-container i18n>This field is required</ng-container>
+ </span>
+ <span class="invalid-feedback"
+ *ngIf="edit && invalidSizeError">
+ <ng-container i18n>Enter a value above than previous.</ng-container>
+ </span>
+ </div>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <div class="text-right">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="nsForm"
+ [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+ </div>
+ </div>
+ </form>
+ </ng-container>
+</cd-modal>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+
+import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form.component';
+import { FormHelper } from '~/testing/unit-test-helper';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+describe('NvmeofNamespacesFormComponent', () => {
+ let component: NvmeofNamespacesFormComponent;
+ let fixture: ComponentFixture<NvmeofNamespacesFormComponent>;
+ let nvmeofService: NvmeofService;
+ let form: CdFormGroup;
+ let formHelper: FormHelper;
+ const mockTimestamp = 1720693470789;
+ const mockSubsystemNQN = 'nqn.2021-11.com.example:subsystem';
+
+ beforeEach(async () => {
+ spyOn(Date, 'now').and.returnValue(mockTimestamp);
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofNamespacesFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofNamespacesFormComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ form = component.nsForm;
+ formHelper = new FormHelper(form);
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('should test form', () => {
+ beforeEach(() => {
+ component.subsystemNQN = mockSubsystemNQN;
+ nvmeofService = TestBed.inject(NvmeofService);
+ spyOn(nvmeofService, 'createNamespace').and.stub();
+ });
+
+ it('should be creating request correctly', () => {
+ const image = 'nvme_ns_image:' + mockTimestamp;
+ component.onSubmit();
+ expect(nvmeofService.createNamespace).toHaveBeenCalledWith(mockSubsystemNQN, {
+ rbd_image_name: image,
+ rbd_pool: null,
+ size: 1073741824
+ });
+ });
+
+ it('should give error on invalid image name', () => {
+ formHelper.setValue('image', '/ghfhdlk;kd;@');
+ component.onSubmit();
+ formHelper.expectError('image', 'pattern');
+ });
+
+ it('should give error on invalid image size', () => {
+ formHelper.setValue('image_size', -56);
+ component.onSubmit();
+ formHelper.expectError('image_size', 'pattern');
+ });
+
+ it('should give error on 0 image size', () => {
+ formHelper.setValue('image_size', 0);
+ component.onSubmit();
+ formHelper.expectError('image_size', 'min');
+ });
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import {
+ NamespaceCreateRequest,
+ NamespaceEditRequest,
+ NvmeofService
+} from '~/app/shared/api/nvmeof.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
+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 { Pool } from '../../pool/pool';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { Observable } from 'rxjs';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+
+@Component({
+ selector: 'cd-nvmeof-namespaces-form',
+ templateUrl: './nvmeof-namespaces-form.component.html',
+ styleUrls: ['./nvmeof-namespaces-form.component.scss']
+})
+export class NvmeofNamespacesFormComponent implements OnInit {
+ action: string;
+ permission: Permission;
+ poolPermission: Permission;
+ resource: string;
+ pageURL: string;
+ edit: boolean = false;
+ nsForm: CdFormGroup;
+ subsystemNQN: string;
+ rbdPools: Array<Pool> = null;
+ units: Array<string> = ['KiB', 'MiB', 'GiB', 'TiB'];
+ nsid: string;
+ currentBytes: number;
+ invalidSizeError: boolean;
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private taskWrapperService: TaskWrapperService,
+ private nvmeofService: NvmeofService,
+ private poolService: PoolService,
+ private rbdService: RbdService,
+ private router: Router,
+ private route: ActivatedRoute,
+ public activeModal: NgbActiveModal,
+ public formatterService: FormatterService,
+ public dimlessBinaryPipe: DimlessBinaryPipe
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ this.poolPermission = this.authStorageService.getPermissions().pool;
+ this.resource = $localize`Namespace`;
+ this.pageURL = 'block/nvmeof/subsystems';
+ }
+
+ init() {
+ this.createForm();
+ this.action = this.actionLabels.CREATE;
+ this.route.params.subscribe((params: { subsystem_nqn: string; nsid: string }) => {
+ this.subsystemNQN = params.subsystem_nqn;
+ this.nsid = params?.nsid;
+ });
+ }
+
+ initForEdit() {
+ this.edit = true;
+ this.action = this.actionLabels.EDIT;
+ this.nvmeofService
+ .getNamespace(this.subsystemNQN, this.nsid)
+ .subscribe((res: NvmeofSubsystemNamespace) => {
+ const convertedSize = this.dimlessBinaryPipe.transform(res.rbd_image_size).split(' ');
+ this.currentBytes = res.rbd_image_size;
+ this.nsForm.get('image').setValue(res.rbd_image_name);
+ this.nsForm.get('pool').setValue(res.rbd_pool_name);
+ this.nsForm.get('unit').setValue(convertedSize[1]);
+ this.nsForm.get('image_size').setValue(convertedSize[0]);
+ this.nsForm.get('image_size').addValidators(Validators.required);
+ this.nsForm.get('image').disable();
+ this.nsForm.get('pool').disable();
+ });
+ }
+
+ initForCreate() {
+ this.poolService.getList().subscribe((resp: Pool[]) => {
+ this.rbdPools = resp.filter(this.rbdService.isRBDPool);
+ });
+ }
+
+ ngOnInit() {
+ this.init();
+ if (this.router.url.includes('subsystems/(modal:edit')) {
+ this.initForEdit();
+ } else {
+ this.initForCreate();
+ }
+ }
+
+ createForm() {
+ this.nsForm = new CdFormGroup({
+ image: new UntypedFormControl(`nvme_ns_image:${Date.now()}`, {
+ validators: [Validators.required, Validators.pattern(/^[^@/]+?$/)]
+ }),
+ pool: new UntypedFormControl(null, {
+ validators: [Validators.required]
+ }),
+ image_size: new UntypedFormControl(1, [CdValidators.number(false), Validators.min(1)]),
+ unit: new UntypedFormControl(this.units[2])
+ });
+ }
+
+ buildRequest(): NamespaceCreateRequest | NamespaceEditRequest {
+ const image_size = this.nsForm.getValue('image_size');
+ const image_size_unit = this.nsForm.getValue('unit');
+ const request = {} as NamespaceCreateRequest | NamespaceEditRequest;
+ if (image_size) {
+ const key: string = this.edit ? 'rbd_image_size' : 'size';
+ const value: number = this.formatterService.toBytes(image_size + image_size_unit);
+ request[key] = value;
+ }
+ if (!this.edit) {
+ const image = this.nsForm.getValue('image');
+ const pool = this.nsForm.getValue('pool');
+ request['rbd_image_name'] = image;
+ request['rbd_pool'] = pool;
+ }
+ return request;
+ }
+
+ validateSize() {
+ const unit = this.nsForm.getValue('unit');
+ const image_size = this.nsForm.getValue('image_size');
+ if (image_size && unit) {
+ const bytes = this.formatterService.toBytes(image_size + unit);
+ return bytes <= this.currentBytes;
+ }
+ return null;
+ }
+
+ onSubmit() {
+ if (this.validateSize()) {
+ this.invalidSizeError = true;
+ this.nsForm.setErrors({ cdSubmitButton: true });
+ } else {
+ this.invalidSizeError = false;
+ const component = this;
+ const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
+ const request = this.buildRequest();
+ let action: Observable<any>;
+
+ if (this.edit) {
+ action = this.taskWrapperService.wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ nqn: this.subsystemNQN,
+ nsid: this.nsid
+ }),
+ call: this.nvmeofService.updateNamespace(
+ this.subsystemNQN,
+ this.nsid,
+ request as NamespaceEditRequest
+ )
+ });
+ } else {
+ action = this.taskWrapperService.wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ nqn: this.subsystemNQN
+ }),
+ call: this.nvmeofService.createNamespace(
+ this.subsystemNQN,
+ request as NamespaceCreateRequest
+ )
+ });
+ }
+
+ action.subscribe({
+ error() {
+ component.nsForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+ }
+ });
+ }
+ }
+}
--- /dev/null
+<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)">
+
+ <div class="table-actions btn-toolbar">
+ <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 { HttpClientModule } from '@angular/common/http';
+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 { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NvmeofTabsComponent } from '../nvmeof-tabs/nvmeof-tabs.component';
+import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
+import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list.component';
+
+const mockNamespaces = [
+ {
+ nsid: 1,
+ uuid: 'f4396245-186f-401a-b71c-945ccf0f0cc9',
+ bdev_name: 'bdev_f4396245-186f-401a-b71c-945ccf0f0cc9',
+ rbd_image_name: 'string',
+ rbd_pool_name: 'rbd',
+ load_balancing_group: 1,
+ rbd_image_size: 1024,
+ block_size: 512,
+ rw_ios_per_second: 0,
+ rw_mbytes_per_second: 0,
+ r_mbytes_per_second: 0,
+ w_mbytes_per_second: 0
+ }
+];
+
+class MockNvmeOfService {
+ listNamespaces() {
+ return of(mockNamespaces);
+ }
+}
+
+class MockAuthStorageService {
+ getPermissions() {
+ return { nvmeof: {} };
+ }
+}
+
+class MockModalService {}
+
+class MockTaskWrapperService {}
+
+describe('NvmeofNamespacesListComponent', () => {
+ let component: NvmeofNamespacesListComponent;
+ let fixture: ComponentFixture<NvmeofNamespacesListComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [
+ NvmeofNamespacesListComponent,
+ NvmeofTabsComponent,
+ NvmeofSubsystemsDetailsComponent
+ ],
+ imports: [HttpClientModule, RouterTestingModule, SharedModule],
+ providers: [
+ { provide: NvmeofService, useClass: MockNvmeOfService },
+ { provide: AuthStorageService, useClass: MockAuthStorageService },
+ { provide: ModalService, useClass: MockModalService },
+ { provide: TaskWrapperService, useClass: MockTaskWrapperService }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofNamespacesListComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436';
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should retrieve namespaces', fakeAsync(() => {
+ component.listNamespaces();
+ tick();
+ expect(component.namespaces).toEqual(mockNamespaces);
+ }));
+});
--- /dev/null
+import { Component, Input, OnChanges, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+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 { MbpersecondPipe } from '~/app/shared/pipes/mbpersecond.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+const BASE_URL = 'block/nvmeof/subsystems';
+
+@Component({
+ selector: 'cd-nvmeof-namespaces-list',
+ templateUrl: './nvmeof-namespaces-list.component.html',
+ styleUrls: ['./nvmeof-namespaces-list.component.scss']
+})
+export class NvmeofNamespacesListComponent implements OnInit, OnChanges {
+ @Input()
+ subsystemNQN: string;
+
+ namespacesColumns: any;
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ permission: Permission;
+ namespaces: NvmeofSubsystemNamespace[];
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private router: Router,
+ private modalService: ModalService,
+ private authStorageService: AuthStorageService,
+ private taskWrapper: TaskWrapperService,
+ private nvmeofService: NvmeofService,
+ private dimlessBinaryPipe: DimlessBinaryPipe,
+ private mbPerSecondPipe: MbpersecondPipe,
+ private iopsPipe: IopsPipe
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ }
+
+ ngOnInit() {
+ this.namespacesColumns = [
+ {
+ name: $localize`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`,
+ 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`Write Throughput`,
+ prop: 'w_mbytes_per_second',
+ sortable: false,
+ pipe: this.mbPerSecondPipe,
+ flexGrow: 1.5
+ },
+ {
+ name: $localize`Load Balancing Group`,
+ prop: 'load_balancing_group',
+ flexGrow: 1.5
+ }
+ ];
+ this.tableActions = [
+ {
+ name: this.actionLabels.CREATE,
+ permission: 'create',
+ icon: Icons.add,
+ click: () =>
+ this.router.navigate([
+ BASE_URL,
+ { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }
+ ]),
+ 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]
+ }
+ }
+ ])
+ },
+ {
+ name: this.actionLabels.DELETE,
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteSubsystemModal()
+ }
+ ];
+ }
+
+ ngOnChanges() {
+ this.listNamespaces();
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ listNamespaces() {
+ this.nvmeofService
+ .listNamespaces(this.subsystemNQN)
+ .subscribe((res: NvmeofSubsystemNamespace[]) => {
+ this.namespaces = res;
+ });
+ }
+
+ deleteSubsystemModal() {
+ const namespace = this.selection.first();
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ 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)
+ })
+ });
+ }
+}
</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"></cd-nvmeof-namespaces-list>
+ </ng-template>
+ </ng-container>
</nav>
<div [ngbNavOutlet]="nav"></div>
type="text"
formControlName="nqn">
<cd-help-text>
- The NVMe Qualified Name (NQN) is a unique and permanent name for the lifetime of the subsystem.
+ A unique and permanent name for the lifetime of the subsystem.
</cd-help-text>
<span class="invalid-feedback"
*ngIf="subsystemForm.showError('nqn', formDir, 'required')"
i18n>This NQN is already in use.</span>
<span class="invalid-feedback"
*ngIf="subsystemForm.showError('nqn', formDir, 'pattern')"
- i18n>An NQN should follow the format of<br/><<code>nqn.$year-$month.$reverseDomainName:$definedName</code>".></span>
+ i18n>Expected NQN format<br/><<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".> or <br/><<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</code>".></span>
<span class="invalid-feedback"
*ngIf="subsystemForm.showError('nqn', formDir, 'maxLength')"
i18n>An NQN should not be more than 223 bytes in length.</span>
const mockTimestamp = 1720693470789;
beforeEach(async () => {
+ spyOn(Date, 'now').and.returnValue(mockTimestamp);
await TestBed.configureTestingModule({
declarations: [NvmeofSubsystemsFormComponent],
providers: [NgbActiveModal],
component.ngOnInit();
form = component.subsystemForm;
formHelper = new FormHelper(form);
- spyOn(Date, 'now').and.returnValue(mockTimestamp);
fixture.detectChanges();
});
resource: string;
pageURL: string;
- NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+)$/;
-
constructor(
private authStorageService: AuthStorageService,
public actionLabels: ActionLabelsI18n,
this.pageURL = 'block/nvmeof/subsystems';
}
+ DEFAULT_NQN = 'nqn.2001-07.com.ceph:' + Date.now();
+ NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
+ NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
+ customNQNValidator = CdValidators.custom(
+ 'pattern',
+ (nqnInput: string) =>
+ !!nqnInput && !(this.NQN_REGEX.test(nqnInput) || this.NQN_REGEX_UUID.test(nqnInput))
+ );
+
ngOnInit() {
this.createForm();
this.action = this.actionLabels.CREATE;
createForm() {
this.subsystemForm = new CdFormGroup({
- nqn: new UntypedFormControl('nqn.2001-07.com.ceph:' + Date.now(), {
+ nqn: new UntypedFormControl(this.DEFAULT_NQN, {
validators: [
+ this.customNQNValidator,
Validators.required,
Validators.pattern(this.NQN_REGEX),
CdValidators.custom(
prop: 'namespace_count'
},
{
- name: $localize`# Maximum Namespaces`,
+ name: $localize`# Maximum Allowed Namespaces`,
prop: 'max_namespaces'
}
];
const req = httpTesting.expectOne('api/nvmeof/gateway');
expect(req.request.method).toBe('GET');
});
+
+ it('should call getSubsystem', () => {
+ service.getSubsystem('nqn.2001-07.com.ceph:1721041732363').subscribe();
+ const req = httpTesting.expectOne('api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363');
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call createSubsystem', () => {
+ const request = {
+ nqn: 'nqn.2001-07.com.ceph:1721041732363',
+ enable_ha: true,
+ initiators: '*'
+ };
+ service.createSubsystem(request).subscribe();
+ const req = httpTesting.expectOne('api/nvmeof/subsystem');
+ expect(req.request.method).toBe('POST');
+ });
+
+ it('should call getInitiators', () => {
+ service.getInitiators('nqn.2001-07.com.ceph:1721041732363').subscribe();
+ const req = httpTesting.expectOne(
+ 'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host'
+ );
+ expect(req.request.method).toBe('GET');
+ });
+
+ it('should call updateInitiators', () => {
+ service.updateInitiators('nqn.2001-07.com.ceph:1721041732363', '*').subscribe();
+ const req = httpTesting.expectOne(
+ 'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host/*'
+ );
+ expect(req.request.method).toBe('PUT');
+ });
});
trsvcid: number;
}
+export interface NamespaceCreateRequest {
+ rbd_image_name: string;
+ rbd_pool: string;
+ size: number;
+}
+
+export interface NamespaceEditRequest {
+ rbd_image_size: number;
+}
+
const BASE_URL = 'api/nvmeof';
@Injectable({
export class NvmeofService {
constructor(private http: HttpClient) {}
+ // Gateways
listGateways() {
return this.http.get(`${BASE_URL}/gateway`);
}
+ // Subsystems
listSubsystems() {
return this.http.get(`${BASE_URL}/subsystem`);
}
);
}
- // listeners
+ // Initiators
+ getInitiators(subsystemNQN: string) {
+ return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/host`);
+ }
+
+ updateInitiators(subsystemNQN: string, hostNQN: string) {
+ return this.http.put(
+ `${BASE_URL}/subsystem/${subsystemNQN}/host/${hostNQN}`,
+ {},
+ {
+ observe: 'response'
+ }
+ );
+ }
+
+ // Listeners
listListeners(subsystemNQN: string) {
return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/listener`);
}
}
);
}
+
+ // Namespaces
+ listNamespaces(subsystemNQN: string) {
+ return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`);
+ }
+
+ getNamespace(subsystemNQN: string, nsid: string) {
+ return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`);
+ }
+
+ createNamespace(subsystemNQN: string, request: NamespaceCreateRequest) {
+ return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`, request, {
+ observe: 'response'
+ });
+ }
+
+ updateNamespace(subsystemNQN: string, nsid: string, request: NamespaceEditRequest) {
+ return this.http.patch(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, {
+ observe: 'response'
+ });
+ }
+
+ deleteNamespace(subsystemNQN: string, nsid: string) {
+ return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, {
+ observe: 'response'
+ });
+ }
}
trsvcid: number; // 4420
id?: number; // for table
}
+
+export interface NvmeofSubsystemHost {
+ nqn: string;
+}
+
+export interface NvmeofSubsystemNamespace {
+ nsid: number;
+ uuid: string;
+ bdev_name: string;
+ rbd_image_name: string;
+ rbd_pool_name: string;
+ load_balancing_group: number;
+ rbd_image_size: number;
+ block_size: number;
+ rw_ios_per_second: number;
+ rw_mbytes_per_second: number;
+ r_mbytes_per_second: number;
+ w_mbytes_per_second: number;
+}
--- /dev/null
+import { MbpersecondPipe } from './mbpersecond.pipe';
+
+describe('MbpersecondPipe', () => {
+ it('create an instance', () => {
+ const pipe = new MbpersecondPipe();
+ expect(pipe).toBeTruthy();
+ });
+});
--- /dev/null
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+ name: 'mbpersecond'
+})
+export class MbpersecondPipe implements PipeTransform {
+ transform(value: any): any {
+ return `${value} MB/s`;
+ }
+}
import { PathPipe } from './path.pipe';
import { PluralizePipe } from './pluralize.pipe';
import { XmlPipe } from './xml.pipe';
+import { MbpersecondPipe } from './mbpersecond.pipe';
@NgModule({
imports: [CommonModule],
OctalToHumanReadablePipe,
PathPipe,
PluralizePipe,
- XmlPipe
+ XmlPipe,
+ MbpersecondPipe
],
exports: [
ArrayPipe,
OctalToHumanReadablePipe,
PathPipe,
PluralizePipe,
- XmlPipe
+ XmlPipe,
+ MbpersecondPipe
],
providers: [
ArrayPipe,
MgrSummaryPipe,
MdsSummaryPipe,
OsdSummaryPipe,
- OctalToHumanReadablePipe
+ OctalToHumanReadablePipe,
+ MbpersecondPipe
]
})
export class PipesModule {}
'nvmeof/listener/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.nvmeofListener(metadata)
),
+ 'nvmeof/subsystem/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+ this.nvmeofSubsystem(metadata)
+ ),
+ 'nvmeof/namespace/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+ this.nvmeofNamespace(metadata)
+ ),
+ 'nvmeof/namespace/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+ this.nvmeofNamespace(metadata)
+ ),
+ 'nvmeof/namespace/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.nvmeofNamespace(metadata)
+ ),
// nfs
'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nfs(metadata)
return $localize`listener '${metadata.host_name} on subsystem ${metadata.nqn}`;
}
+ nvmeofNamespace(metadata: any) {
+ if (metadata?.nsid) {
+ return $localize`namespace ${metadata.nsid} for subsystem '${metadata.nqn}'`;
+ }
+ return $localize`namespace for subsystem '${metadata.nqn}'`;
+ }
+
nfs(metadata: any) {
return $localize`NFS '${metadata.cluster_id}\:${
metadata.export_id ? metadata.export_id : metadata.path