import { NvmeofSubsystemsDetailsComponent } from './nvmeof-subsystems-details/nvmeof-subsystems-details.component';
import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component';
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';
@NgModule({
imports: [
NvmeofSubsystemsComponent,
NvmeofSubsystemsDetailsComponent,
NvmeofTabsComponent,
- NvmeofSubsystemsFormComponent
+ NvmeofSubsystemsFormComponent,
+ NvmeofListenersFormComponent,
+ NvmeofListenersListComponent
],
exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
})
path: `${URLVerbs.EDIT}/:subsystem_nqn`,
component: NvmeofSubsystemsFormComponent,
outlet: 'modal'
+ },
+ {
+ path: `${URLVerbs.CREATE}/:subsystem_nqn/listener`,
+ component: NvmeofListenersFormComponent,
+ outlet: 'modal'
}
]
},
--- /dev/null
+<cd-modal [pageURL]="pageURL">
+ <span class="modal-title"
+ i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+ <ng-container class="modal-content">
+ <form name="listenerForm"
+ #formDir="ngForm"
+ [formGroup]="listenerForm"
+ novalidate>
+ <div class="modal-body">
+ <!-- Host -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="host">
+ <span class="required"
+ i18n>Host Name</span>
+ </label>
+ <div class="cd-col-form-input">
+ <select id="host"
+ name="host"
+ class="form-select"
+ formControlName="host">
+ <option *ngIf="hosts === null"
+ [ngValue]="null"
+ i18n>Loading...</option>
+ <option *ngIf="hosts && hosts.length === 0"
+ [ngValue]="null"
+ i18n>-- No hosts available --</option>
+ <option *ngIf="hosts && hosts.length > 0"
+ [ngValue]="null"
+ i18n>-- Select a host --</option>
+ <option *ngFor="let hostsItem of hosts"
+ [ngValue]="hostsItem">{{ hostsItem.hostname }}</option>
+ </select>
+ <cd-help-text i18n>
+ This hostname uniquely identifies the gateway on which the listener is being set up.
+ </cd-help-text>
+ <span class="invalid-feedback"
+ *ngIf="listenerForm.showError('host', formDir, 'required')"
+ i18n>This field is required.</span>
+ </div>
+ </div>
+ <!-- Transport Service ID -->
+ <div class="form-group row">
+ <label class="cd-col-form-label"
+ for="trsvcid">
+ <span i18n>Transport Service ID</span>
+ </label>
+ <div class="cd-col-form-input">
+ <input id="trsvcid"
+ class="form-control"
+ type="text"
+ name="trsvcid"
+ formControlName="trsvcid">
+ <cd-help-text i18n>The IP port to use. Default is 4420.</cd-help-text>
+ <span class="invalid-feedback"
+ *ngIf="listenerForm.showError('trsvcid', formDir, 'required')"
+ i18n>This field is required.</span>
+ <span class="invalid-feedback"
+ *ngIf="listenerForm.showError('trsvcid', formDir, 'max')"
+ i18n>The value cannot be greated than 65535.</span>
+ <span class="invalid-feedback"
+ *ngIf="listenerForm.showError('trsvcid', formDir, 'pattern')"
+ i18n>The value must be a positive integer.</span>
+ </div>
+ </div>
+ </div>
+ <div class="modal-footer">
+ <div class="text-right">
+ <cd-form-button-panel (submitActionEvent)="onSubmit()"
+ [form]="listenerForm"
+ [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 { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { NvmeofListenersFormComponent } from './nvmeof-listeners-form.component';
+
+describe('NvmeofListenersFormComponent', () => {
+ let component: NvmeofListenersFormComponent;
+ let fixture: ComponentFixture<NvmeofListenersFormComponent>;
+ let nvmeofService: NvmeofService;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofListenersFormComponent],
+ providers: [NgbActiveModal],
+ imports: [
+ HttpClientTestingModule,
+ NgbTypeaheadModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastrModule.forRoot()
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(NvmeofListenersFormComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('should test form', () => {
+ beforeEach(() => {
+ nvmeofService = TestBed.inject(NvmeofService);
+ spyOn(nvmeofService, 'createListener').and.stub();
+ });
+ });
+});
--- /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 { ListenerRequest, 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 { 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 { FormatterService } from '~/app/shared/services/formatter.service';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { HostService } from '~/app/shared/api/host.service';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+@Component({
+ selector: 'cd-nvmeof-listeners-form',
+ templateUrl: './nvmeof-listeners-form.component.html',
+ styleUrls: ['./nvmeof-listeners-form.component.scss']
+})
+export class NvmeofListenersFormComponent implements OnInit {
+ action: string;
+ permission: Permission;
+ hostPermission: Permission;
+ resource: string;
+ pageURL: string;
+ listenerForm: CdFormGroup;
+ subsystemNQN: string;
+ hosts: Array<object> = null;
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private authStorageService: AuthStorageService,
+ private taskWrapperService: TaskWrapperService,
+ private nvmeofService: NvmeofService,
+ private hostService: HostService,
+ private router: Router,
+ private route: ActivatedRoute,
+ public activeModal: NgbActiveModal,
+ public formatterService: FormatterService,
+ public dimlessBinaryPipe: DimlessBinaryPipe
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ this.hostPermission = this.authStorageService.getPermissions().hosts;
+ this.resource = $localize`Listener`;
+ this.pageURL = 'block/nvmeof/subsystems';
+ }
+
+ setHosts() {
+ const hostContext = new CdTableFetchDataContext(() => undefined);
+ this.hostService.list(hostContext.toParams(), 'false').subscribe((resp: any[]) => {
+ const nvmeofHosts = resp.filter((r) =>
+ r.service_instances.some((si: any) => si.type === 'nvmeof')
+ );
+ this.hosts = nvmeofHosts.map((h) => ({ hostname: h.hostname, addr: h.addr }));
+ });
+ }
+
+ ngOnInit() {
+ this.createForm();
+ this.action = this.actionLabels.CREATE;
+ this.route.params.subscribe((params: { subsystem_nqn: string }) => {
+ this.subsystemNQN = params.subsystem_nqn;
+ });
+ this.setHosts();
+ }
+
+ createForm() {
+ this.listenerForm = new CdFormGroup({
+ host: new UntypedFormControl(null, {
+ validators: [Validators.required]
+ }),
+ trsvcid: new UntypedFormControl(4420, [
+ Validators.required,
+ CdValidators.number(false),
+ Validators.max(65535)
+ ])
+ });
+ }
+
+ buildRequest(): ListenerRequest {
+ const host = this.listenerForm.getValue('host');
+ let trsvcid = Number(this.listenerForm.getValue('trsvcid'));
+ if (!trsvcid) trsvcid = 4420;
+ const request = {
+ host_name: host.hostname,
+ traddr: host.addr,
+ trsvcid
+ };
+ return request;
+ }
+
+ onSubmit() {
+ const component = this;
+ const taskUrl: string = `nvmeof/listener/${URLVerbs.CREATE}`;
+ const request = this.buildRequest();
+ this.taskWrapperService
+ .wrapTaskAroundCall({
+ task: new FinishedTask(taskUrl, {
+ nqn: this.subsystemNQN,
+ host_name: request.host_name
+ }),
+ call: this.nvmeofService.createListener(this.subsystemNQN, request)
+ })
+ .subscribe({
+ error() {
+ component.listenerForm.setErrors({ cdSubmitButton: true });
+ },
+ complete: () => {
+ this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+ }
+ });
+ }
+}
--- /dev/null
+<legend>
+ <cd-help-text>
+ A listener defines the IP port on the gateway that is to process NVMe/TCP commands and I/O operations.
+ </cd-help-text>
+</legend>
+<cd-table [data]="listeners"
+ columnMode="flex"
+ (fetchData)="listListeners()"
+ [columns]="listenerColumns"
+ identifier="id"
+ forceIdentifier="true"
+ 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 { NvmeofListenersListComponent } from './nvmeof-listeners-list.component';
+import { HttpClientModule } from '@angular/common/http';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofService } from '~/app/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 { of } from 'rxjs';
+
+const mockListeners = [
+ {
+ host_name: 'ceph-node-02',
+ trtype: 'TCP',
+ traddr: '192.168.100.102',
+ adrfam: 0,
+ trsvcid: 4421
+ }
+];
+
+class MockNvmeOfService {
+ listListeners() {
+ return of(mockListeners);
+ }
+}
+
+class MockAuthStorageService {
+ getPermissions() {
+ return { nvmeof: {} };
+ }
+}
+
+class MockModalService {}
+
+class MockTaskWrapperService {}
+
+describe('NvmeofListenersListComponent', () => {
+ let component: NvmeofListenersListComponent;
+ let fixture: ComponentFixture<NvmeofListenersListComponent>;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ declarations: [NvmeofListenersListComponent],
+ 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(NvmeofListenersListComponent);
+ component = fixture.componentInstance;
+ component.ngOnInit();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should retrieve subsystems', fakeAsync(() => {
+ component.listListeners();
+ tick();
+ expect(component.listeners).toEqual(mockListeners);
+ }));
+});
--- /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 { CellTemplate } from '~/app/shared/enum/cell-template.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 { NvmeofListener } from '~/app/shared/models/nvmeof';
+import { Permission } from '~/app/shared/models/permissions';
+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-listeners-list',
+ templateUrl: './nvmeof-listeners-list.component.html',
+ styleUrls: ['./nvmeof-listeners-list.component.scss']
+})
+export class NvmeofListenersListComponent implements OnInit, OnChanges {
+ @Input()
+ subsystemNQN: string;
+
+ listenerColumns: any;
+ tableActions: CdTableAction[];
+ selection = new CdTableSelection();
+ permission: Permission;
+ listeners: NvmeofListener[];
+
+ constructor(
+ public actionLabels: ActionLabelsI18n,
+ private modalService: ModalService,
+ private authStorageService: AuthStorageService,
+ private taskWrapper: TaskWrapperService,
+ private nvmeofService: NvmeofService,
+ private router: Router
+ ) {
+ this.permission = this.authStorageService.getPermissions().nvmeof;
+ }
+
+ ngOnInit() {
+ this.listenerColumns = [
+ {
+ name: $localize`Host`,
+ prop: 'host_name'
+ },
+ {
+ name: $localize`Transport`,
+ prop: 'trtype'
+ },
+ {
+ name: $localize`Address`,
+ prop: 'full_addr',
+ cellTransformation: CellTemplate.copy
+ }
+ ];
+ this.tableActions = [
+ {
+ name: this.actionLabels.CREATE,
+ permission: 'create',
+ icon: Icons.add,
+ click: () =>
+ this.router.navigate([
+ BASE_URL,
+ { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'listener'] } }
+ ]),
+ canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+ },
+ {
+ name: this.actionLabels.DELETE,
+ permission: 'delete',
+ icon: Icons.destroy,
+ click: () => this.deleteSubsystemModal()
+ }
+ ];
+ }
+
+ ngOnChanges() {
+ this.listListeners();
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ listListeners() {
+ this.nvmeofService
+ .listListeners(this.subsystemNQN)
+ .subscribe((listResponse: NvmeofListener[]) => {
+ this.listeners = listResponse.map((listener, index) => {
+ listener['id'] = index;
+ listener['full_addr'] = `${listener.traddr}:${listener.trsvcid}`;
+ return listener;
+ });
+ });
+ }
+
+ deleteSubsystemModal() {
+ const listener = this.selection.first();
+ this.modalService.show(CriticalConfirmationModalComponent, {
+ itemDescription: 'Listener',
+ actionDescription: 'delete',
+ itemNames: [`listener ${listener.host_name} (${listener.traddr}:${listener.trsvcid})`],
+ submitActionObservable: () =>
+ this.taskWrapper.wrapTaskAroundCall({
+ task: new FinishedTask('nvmeof/listener/delete', {
+ nqn: this.subsystemNQN,
+ host_name: listener.host_name
+ }),
+ call: this.nvmeofService.deleteListener(
+ this.subsystemNQN,
+ listener.host_name,
+ listener.traddr,
+ listener.trsvcid
+ )
+ })
+ });
+ }
+}
</cd-table-key-value>
</ng-template>
</ng-container>
+ <ng-container ngbNavItem="listeners">
+ <a ngbNavLink
+ i18n>Listeners</a>
+ <ng-template ngbNavContent>
+ <cd-nvmeof-listeners-list [subsystemNQN]="subsystemNQN">
+ </cd-nvmeof-listeners-list>
+ </ng-template>
+ </ng-container>
</nav>
<div [ngbNavOutlet]="nav"></div>
selectedItem: any;
data: any;
+ subsystemNQN: string;
ngOnChanges() {
if (this.selection) {
this.selectedItem = this.selection;
+ this.subsystemNQN = this.selectedItem.nqn;
this.data = {};
this.data[$localize`Serial Number`] = this.selectedItem.serial_number;
this.data[$localize`Model Number`] = this.selectedItem.model_number;
import { Observable, of as observableOf } from 'rxjs';
import { catchError, mapTo } from 'rxjs/operators';
+export interface ListenerRequest {
+ host_name: string;
+ traddr: string;
+ trsvcid: number;
+}
+
const BASE_URL = 'api/nvmeof';
@Injectable({
})
);
}
+
+ // listeners
+ listListeners(subsystemNQN: string) {
+ return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/listener`);
+ }
+
+ createListener(subsystemNQN: string, request: ListenerRequest) {
+ return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/listener`, request, {
+ observe: 'response'
+ });
+ }
+
+ deleteListener(subsystemNQN: string, hostName: string, traddr: string, trsvcid: string) {
+ return this.http.delete(
+ `${BASE_URL}/subsystem/${subsystemNQN}/listener/${hostName}/${traddr}`,
+ {
+ observe: 'response',
+ params: {
+ trsvcid
+ }
+ }
+ );
+ }
}
</cd-copy-2-clipboard-button>
</span>
</ng-template>
+
+<ng-template #copyTpl
+ let-value="value">
+ <span class="font-monospace">{{value}}</span>
+ <cd-copy-2-clipboard-button *ngIf="value"
+ [source]="value"
+ [byId]="false"
+ [showIconOnly]="true">
+ </cd-copy-2-clipboard-button>
+</ng-template>
pathTpl: TemplateRef<any>;
@ViewChild('tooltipTpl', { static: true })
tooltipTpl: TemplateRef<any>;
+ @ViewChild('copyTpl', { static: true })
+ copyTpl: TemplateRef<any>;
// This is the array with the items to be shown.
@Input()
this.cellTemplates.timeAgo = this.timeAgoTpl;
this.cellTemplates.path = this.pathTpl;
this.cellTemplates.tooltip = this.tooltipTpl;
+ this.cellTemplates.copy = this.copyTpl;
}
useCustomClass(value: any): string {
// }
// }
*/
- tooltip = 'tooltip'
+ tooltip = 'tooltip',
+ /*
+ This template is used to attach copy to clipboard functionality to the given column value
+ // {
+ // ...
+ // cellTransformation: CellTemplate.copy,
+ */
+ copy = 'copy'
}
subtype: string;
max_namespaces: number;
}
+
+export interface NvmeofListener {
+ host_name: string;
+ trtype: string;
+ traddr: string;
+ adrfam: number; // 0: IPv4, 1: IPv6
+ trsvcid: number; // 4420
+ id?: number; // for table
+}
'iscsi/target/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.iscsiTarget(metadata)
),
- // NVME/TCP tasks
+ // nvmeof
'nvmeof/subsystem/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nvmeofSubsystem(metadata)
),
'nvmeof/subsystem/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
this.nvmeofSubsystem(metadata)
),
+ 'nvmeof/listener/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+ this.nvmeofListener(metadata)
+ ),
+ 'nvmeof/listener/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+ this.nvmeofListener(metadata)
+ ),
+ // nfs
'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
this.nfs(metadata)
),
return $localize`subsystem '${metadata.nqn}'`;
}
+ nvmeofListener(metadata: any) {
+ return $localize`listener '${metadata.host_name} on subsystem ${metadata.nqn}`;
+ }
+
nfs(metadata: any) {
return $localize`NFS '${metadata.cluster_id}\:${
metadata.export_id ? metadata.export_id : metadata.path
logger = logging.getLogger('nvmeof_conf')
+
class NvmeofGatewayAlreadyExists(Exception):
def __init__(self, gateway_name):
super(NvmeofGatewayAlreadyExists, self).__init__(