]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Configure subsystems from dashboard 58599/head
authorAfreen Misbah <afreen23.git@gmail.com>
Sat, 22 Jun 2024 12:00:49 +0000 (17:30 +0530)
committerAfreen Misbah <afreen23.git@gmail.com>
Mon, 15 Jul 2024 15:24:06 +0000 (20:54 +0530)
Fixes https://tracker.ceph.com/issues/66659

- adds subsytems tab
- adds subsystem listing view
- adds create subsystem modal
- adds delete subsystem
- adds unit tests

Signed-off-by: Afreen Misbah <afreen23.git@gmail.com>
(cherry picked from commit b85f982def41320003ecb632e2b03a8779567fbe)

22 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index 30a8d21d2c3f32b498f91bf0d2a742dc4c816903..8e926a40d99ed8c5dc465fd38e207807d9380bf8 100644 (file)
@@ -39,6 +39,10 @@ import { RbdTrashMoveModalComponent } from './rbd-trash-move-modal/rbd-trash-mov
 import { RbdTrashPurgeModalComponent } from './rbd-trash-purge-modal/rbd-trash-purge-modal.component';
 import { RbdTrashRestoreModalComponent } from './rbd-trash-restore-modal/rbd-trash-restore-modal.component';
 import { NvmeofGatewayComponent } from './nvmeof-gateway/nvmeof-gateway.component';
+import { NvmeofSubsystemsComponent } from './nvmeof-subsystems/nvmeof-subsystems.component';
+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';
 
 @NgModule({
   imports: [
@@ -79,7 +83,11 @@ import { NvmeofGatewayComponent } from './nvmeof-gateway/nvmeof-gateway.componen
     RbdConfigurationFormComponent,
     RbdTabsComponent,
     RbdPerformanceComponent,
-    NvmeofGatewayComponent
+    NvmeofGatewayComponent,
+    NvmeofSubsystemsComponent,
+    NvmeofSubsystemsDetailsComponent,
+    NvmeofTabsComponent,
+    NvmeofSubsystemsFormComponent
   ],
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
@@ -220,7 +228,25 @@ const routes: Routes = [
       }
     },
     children: [
-      { path: '', redirectTo: 'gateways', pathMatch: 'full' },
+      { path: '', redirectTo: 'subsystems', pathMatch: 'full' },
+      {
+        path: 'subsystems',
+        component: NvmeofSubsystemsComponent,
+        data: { breadcrumbs: 'Subsystems' },
+        children: [
+          { path: '', component: NvmeofSubsystemsComponent },
+          {
+            path: URLVerbs.CREATE,
+            component: NvmeofSubsystemsFormComponent,
+            outlet: 'modal'
+          },
+          {
+            path: `${URLVerbs.EDIT}/:subsystem_nqn`,
+            component: NvmeofSubsystemsFormComponent,
+            outlet: 'modal'
+          }
+        ]
+      },
       { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } }
     ]
   }
index 3d27a77b1a846c2eef3da740b1abf0f3f74a80e8..18a4000e14de6923ade0fa6749512f3557d20235 100644 (file)
@@ -1,12 +1,4 @@
-<ul class="nav nav-tabs">
-  <li class="nav-item">
-    <a class="nav-link"
-       routerLink="/block/nvmeof/gateways"
-       routerLinkActive="active"
-       ariaCurrentWhenActive="page"
-       i18n>Gateways</a>
-  </li>
-</ul>
+<cd-nvmeof-tabs></cd-nvmeof-tabs>
 
 <legend i18n>
   Gateways
index 7d5b3fbe9fe9e3a1d3f8bb802803170ebc1f23d7..46600388bd96500c6d6e93cdd594582f54f30853 100644 (file)
@@ -1,7 +1,6 @@
-import { Component, OnInit } from '@angular/core';
+import { Component } from '@angular/core';
 
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
-import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { NvmeofGateway } from '~/app/shared/models/nvmeof';
 
@@ -12,14 +11,12 @@ import { NvmeofService } from '../../../shared/api/nvmeof.service';
   templateUrl: './nvmeof-gateway.component.html',
   styleUrls: ['./nvmeof-gateway.component.scss']
 })
-export class NvmeofGatewayComponent extends ListWithDetails implements OnInit {
+export class NvmeofGatewayComponent {
   gateways: NvmeofGateway[] = [];
   gatewayColumns: any;
   selection = new CdTableSelection();
 
-  constructor(private nvmeofService: NvmeofService, public actionLabels: ActionLabelsI18n) {
-    super();
-  }
+  constructor(private nvmeofService: NvmeofService, public actionLabels: ActionLabelsI18n) {}
 
   ngOnInit() {
     this.gatewayColumns = [
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html
new file mode 100644 (file)
index 0000000..56a05df
--- /dev/null
@@ -0,0 +1,17 @@
+<ng-container *ngIf="selection">
+  <nav ngbNav
+       #nav="ngbNav"
+       class="nav-tabs"
+       cdStatefulTab="subsystem-details">
+    <ng-container ngbNavItem="details">
+      <a ngbNavLink
+         i18n>Details</a>
+      <ng-template ngbNavContent>
+        <cd-table-key-value [data]="data">
+        </cd-table-key-value>
+      </ng-template>
+    </ng-container>
+  </nav>
+
+  <div [ngbNavOutlet]="nav"></div>
+</ng-container>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.spec.ts
new file mode 100644 (file)
index 0000000..80cdf92
--- /dev/null
@@ -0,0 +1,49 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { SharedModule } from '~/app/shared/shared.module';
+import { NvmeofSubsystemsDetailsComponent } from './nvmeof-subsystems-details.component';
+
+describe('NvmeofSubsystemsDetailsComponent', () => {
+  let component: NvmeofSubsystemsDetailsComponent;
+  let fixture: ComponentFixture<NvmeofSubsystemsDetailsComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofSubsystemsDetailsComponent],
+      imports: [BrowserAnimationsModule, SharedModule, HttpClientTestingModule, NgbNavModule]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofSubsystemsDetailsComponent);
+    component = fixture.componentInstance;
+    component.selection = {
+      serial_number: 'Ceph30487186726692',
+      model_number: 'Ceph bdev Controller',
+      min_cntlid: 1,
+      max_cntlid: 2040,
+      subtype: 'NVMe',
+      nqn: 'nqn.2001-07.com.ceph:1720603703820',
+      namespace_count: 1,
+      max_namespaces: 256
+    };
+    component.ngOnChanges();
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should prepare data', () => {
+    expect(component.data).toEqual({
+      'Serial Number': 'Ceph30487186726692',
+      'Model Number': 'Ceph bdev Controller',
+      'Minimum Controller Identifier': 1,
+      'Maximum Controller Identifier': 2040,
+      'Subsystem Type': 'NVMe'
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts
new file mode 100644 (file)
index 0000000..a79b01d
--- /dev/null
@@ -0,0 +1,27 @@
+import { Component, Input, OnChanges } from '@angular/core';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+
+@Component({
+  selector: 'cd-nvmeof-subsystems-details',
+  templateUrl: './nvmeof-subsystems-details.component.html',
+  styleUrls: ['./nvmeof-subsystems-details.component.scss']
+})
+export class NvmeofSubsystemsDetailsComponent implements OnChanges {
+  @Input()
+  selection: NvmeofSubsystem;
+
+  selectedItem: any;
+  data: any;
+
+  ngOnChanges() {
+    if (this.selection) {
+      this.selectedItem = this.selection;
+      this.data = {};
+      this.data[$localize`Serial Number`] = this.selectedItem.serial_number;
+      this.data[$localize`Model Number`] = this.selectedItem.model_number;
+      this.data[$localize`Minimum Controller Identifier`] = this.selectedItem.min_cntlid;
+      this.data[$localize`Maximum Controller Identifier`] = this.selectedItem.max_cntlid;
+      this.data[$localize`Subsystem Type`] = this.selectedItem.subtype;
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html
new file mode 100644 (file)
index 0000000..93e2709
--- /dev/null
@@ -0,0 +1,74 @@
+<cd-modal [pageURL]="pageURL"
+          [modalRef]="activeModal">
+  <span class="modal-title"
+        i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+  <ng-container class="modal-content">
+    <form name="subsystemForm"
+          #formDir="ngForm"
+          [formGroup]="subsystemForm"
+          novalidate>
+      <div class="modal-body">
+        <!-- NQN -->
+        <div class="form-group row">
+          <label class="cd-col-form-label"
+                 for="nqn">
+            <span class="required"
+                  i18n>NQN</span>
+          </label>
+          <div class="cd-col-form-input">
+            <input name="nqn"
+                   class="form-control"
+                   type="text"
+                   formControlName="nqn">
+              <cd-help-text>
+                The NVMe Qualified Name (NQN) is 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 field is required.</span>
+            <span class="invalid-feedback"
+                  *ngIf="subsystemForm.showError('nqn', formDir, 'unique')"
+                  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/>&lt;<code>nqn.$year-$month.$reverseDomainName:$definedName</code>".&gt;</span>
+            <span class="invalid-feedback"
+                  *ngIf="subsystemForm.showError('nqn', formDir, 'maxLength')"
+                  i18n>An NQN should not be more than 223 bytes in length.</span>
+          </div>
+        </div>
+        <!-- Maximum Namespaces -->
+        <div class="form-group row">
+          <label class="cd-col-form-label"
+                 for="max_namespaces">
+            <span i18n>Maximum Namespaces</span>
+          </label>
+          <div class="cd-col-form-input">
+            <input id="max_namespaces"
+                   class="form-control"
+                   type="text"
+                   name="max_namespaces"
+                   formControlName="max_namespaces">
+            <cd-help-text i18n>The maximum namespaces per subsystem. Default is 256.</cd-help-text>
+            <span class="invalid-feedback"
+                  *ngIf="subsystemForm.showError('max_namespaces', formDir, 'min')"
+                  i18n>The value must be at least 1.</span>
+            <span class="invalid-feedback"
+                  *ngIf="subsystemForm.showError('max_namespaces', formDir, 'max')"
+                  i18n>The value cannot be greated than 256.</span>
+            <span class="invalid-feedback"
+                  *ngIf="subsystemForm.showError('max_namespaces', 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]="subsystemForm"
+                                [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+        </div>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts
new file mode 100644 (file)
index 0000000..bfa642d
--- /dev/null
@@ -0,0 +1,91 @@
+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 { NvmeofSubsystemsFormComponent } from './nvmeof-subsystems-form.component';
+import { FormHelper } from '~/testing/unit-test-helper';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+describe('NvmeofSubsystemsFormComponent', () => {
+  let component: NvmeofSubsystemsFormComponent;
+  let fixture: ComponentFixture<NvmeofSubsystemsFormComponent>;
+  let nvmeofService: NvmeofService;
+  let form: CdFormGroup;
+  let formHelper: FormHelper;
+  const mockTimestamp = 1720693470789;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofSubsystemsFormComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgbTypeaheadModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule,
+        ToastrModule.forRoot()
+      ]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofSubsystemsFormComponent);
+    component = fixture.componentInstance;
+    component.ngOnInit();
+    form = component.subsystemForm;
+    formHelper = new FormHelper(form);
+    spyOn(Date, 'now').and.returnValue(mockTimestamp);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('should test form', () => {
+    beforeEach(() => {
+      nvmeofService = TestBed.inject(NvmeofService);
+      spyOn(nvmeofService, 'createSubsystem').and.stub();
+    });
+
+    it('should be creating request correctly', () => {
+      const expectedNqn = 'nqn.2001-07.com.ceph:' + mockTimestamp;
+      component.onSubmit();
+      expect(nvmeofService.createSubsystem).toHaveBeenCalledWith({
+        nqn: expectedNqn,
+        max_namespaces: 256,
+        enable_ha: true
+      });
+    });
+
+    it('should give error on invalid nqn', () => {
+      formHelper.setValue('nqn', 'nqn:2001-07.com.ceph:');
+      component.onSubmit();
+      formHelper.expectError('nqn', 'pattern');
+    });
+
+    it('should give error on invalid max_namespaces', () => {
+      formHelper.setValue('max_namespaces', -56);
+      component.onSubmit();
+      formHelper.expectError('max_namespaces', 'pattern');
+    });
+
+    it('should give error on max_namespaces greater than 256', () => {
+      formHelper.setValue('max_namespaces', 300);
+      component.onSubmit();
+      formHelper.expectError('max_namespaces', 'max');
+    });
+
+    it('should give error on max_namespaces lesser than 1', () => {
+      formHelper.setValue('max_namespaces', 0);
+      component.onSubmit();
+      formHelper.expectError('max_namespaces', 'min');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
new file mode 100644 (file)
index 0000000..2af218a
--- /dev/null
@@ -0,0 +1,102 @@
+import { Component, OnInit } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+
+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 { 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 { FinishedTask } from '~/app/shared/models/finished-task';
+import { Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+@Component({
+  selector: 'cd-nvmeof-subsystems-form',
+  templateUrl: './nvmeof-subsystems-form.component.html',
+  styleUrls: ['./nvmeof-subsystems-form.component.scss']
+})
+export class NvmeofSubsystemsFormComponent implements OnInit {
+  permission: Permission;
+  subsystemForm: CdFormGroup;
+
+  action: string;
+  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,
+    public activeModal: NgbActiveModal,
+    private nvmeofService: NvmeofService,
+    private taskWrapperService: TaskWrapperService,
+    private router: Router
+  ) {
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+    this.resource = $localize`Subsystem`;
+    this.pageURL = 'block/nvmeof/subsystems';
+  }
+
+  ngOnInit() {
+    this.createForm();
+    this.action = this.actionLabels.CREATE;
+  }
+
+  createForm() {
+    this.subsystemForm = new CdFormGroup({
+      nqn: new UntypedFormControl('nqn.2001-07.com.ceph:' + Date.now(), {
+        validators: [
+          Validators.required,
+          Validators.pattern(this.NQN_REGEX),
+          CdValidators.custom(
+            'maxLength',
+            (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
+          )
+        ],
+        asyncValidators: [
+          CdValidators.unique(this.nvmeofService.isSubsystemPresent, this.nvmeofService)
+        ]
+      }),
+      max_namespaces: new UntypedFormControl(256, {
+        validators: [CdValidators.number(false), Validators.max(256), Validators.min(1)]
+      })
+    });
+  }
+
+  onSubmit() {
+    const component = this;
+    const nqn: string = this.subsystemForm.getValue('nqn');
+    let max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces'));
+
+    const request = {
+      nqn,
+      max_namespaces,
+      enable_ha: true
+    };
+
+    if (!max_namespaces) {
+      delete request.max_namespaces;
+    }
+
+    let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
+
+    this.taskWrapperService
+      .wrapTaskAroundCall({
+        task: new FinishedTask(taskUrl, {
+          nqn: nqn
+        }),
+        call: this.nvmeofService.createSubsystem(request)
+      })
+      .subscribe({
+        error() {
+          component.subsystemForm.setErrors({ cdSubmitButton: true });
+        },
+        complete: () => {
+          this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+        }
+      });
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.html
new file mode 100644 (file)
index 0000000..4dc0443
--- /dev/null
@@ -0,0 +1,29 @@
+<cd-nvmeof-tabs></cd-nvmeof-tabs>
+<legend i18n>
+  Subsystems
+  <cd-help-text>
+    A subsystem presents a collection of controllers which are used to access namespaces.
+  </cd-help-text>
+</legend>
+<cd-table [data]="subsystems"
+          columnMode="flex"
+          (fetchData)="getSubsystems()"
+          [columns]="subsystemsColumns"
+          selectionType="single"
+          [hasDetails]="true"
+          (setExpandedRow)="setExpandedRow($event)"
+          (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-nvmeof-subsystems-details cdTableDetail
+                                [selection]="expandedRow">
+  </cd-nvmeof-subsystems-details>
+</cd-table>
+<router-outlet name="modal"></router-outlet>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.spec.ts
new file mode 100644 (file)
index 0000000..1efd28d
--- /dev/null
@@ -0,0 +1,80 @@
+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 { NvmeofSubsystemsComponent } from './nvmeof-subsystems.component';
+import { NvmeofTabsComponent } from '../nvmeof-tabs/nvmeof-tabs.component';
+import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
+
+const mockSubsystems = [
+  {
+    nqn: 'nqn.2001-07.com.ceph:1720603703820',
+    enable_ha: true,
+    serial_number: 'Ceph30487186726692',
+    model_number: 'Ceph bdev Controller',
+    min_cntlid: 1,
+    max_cntlid: 2040,
+    namespace_count: 0,
+    subtype: 'NVMe',
+    max_namespaces: 256
+  }
+];
+
+class MockNvmeOfService {
+  listSubsystems() {
+    return of(mockSubsystems);
+  }
+}
+
+class MockAuthStorageService {
+  getPermissions() {
+    return { nvmeof: {} };
+  }
+}
+
+class MockModalService {}
+
+class MockTaskWrapperService {}
+
+describe('NvmeofSubsystemsComponent', () => {
+  let component: NvmeofSubsystemsComponent;
+  let fixture: ComponentFixture<NvmeofSubsystemsComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [
+        NvmeofSubsystemsComponent,
+        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(NvmeofSubsystemsComponent);
+    component = fixture.componentInstance;
+    component.ngOnInit();
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should retrieve subsystems', fakeAsync(() => {
+    component.getSubsystems();
+    tick();
+    expect(component.subsystems).toEqual(mockSubsystems);
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts
new file mode 100644 (file)
index 0000000..d281901
--- /dev/null
@@ -0,0 +1,103 @@
+import { Component, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { NvmeofSubsystem } from '~/app/shared/models/nvmeof';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ListWithDetails } from '~/app/shared/classes/list-with-details.class';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+const BASE_URL = 'block/nvmeof/subsystems';
+
+@Component({
+  selector: 'cd-nvmeof-subsystems',
+  templateUrl: './nvmeof-subsystems.component.html',
+  styleUrls: ['./nvmeof-subsystems.component.scss']
+})
+export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit {
+  subsystems: NvmeofSubsystem[] = [];
+  subsystemsColumns: any;
+  permission: Permission;
+  selection = new CdTableSelection();
+  tableActions: CdTableAction[];
+  subsystemDetails: any[];
+
+  constructor(
+    private nvmeofService: NvmeofService,
+    private authStorageService: AuthStorageService,
+    public actionLabels: ActionLabelsI18n,
+    private router: Router,
+    private modalService: ModalService,
+    private taskWrapper: TaskWrapperService
+  ) {
+    super();
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+  }
+
+  ngOnInit() {
+    this.subsystemsColumns = [
+      {
+        name: $localize`NQN`,
+        prop: 'nqn'
+      },
+      {
+        name: $localize`# Namespaces`,
+        prop: 'namespace_count'
+      },
+      {
+        name: $localize`# Maximum Namespaces`,
+        prop: 'max_namespaces'
+      }
+    ];
+    this.tableActions = [
+      {
+        name: this.actionLabels.CREATE,
+        permission: 'create',
+        icon: Icons.add,
+        click: () => this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]),
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+      },
+      {
+        name: this.actionLabels.DELETE,
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.deleteSubsystemModal()
+      }
+    ];
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  getSubsystems() {
+    this.nvmeofService
+      .listSubsystems()
+      .subscribe((subsystems: NvmeofSubsystem[] | NvmeofSubsystem) => {
+        if (Array.isArray(subsystems)) this.subsystems = subsystems;
+        else this.subsystems = [subsystems];
+      });
+  }
+
+  deleteSubsystemModal() {
+    const subsystem = this.selection.first();
+    this.modalService.show(CriticalConfirmationModalComponent, {
+      itemDescription: 'Subsystem',
+      itemNames: [subsystem.nqn],
+      actionDescription: 'delete',
+      submitActionObservable: () =>
+        this.taskWrapper.wrapTaskAroundCall({
+          task: new FinishedTask('nvmeof/subsystem/delete', { nqn: subsystem.nqn }),
+          call: this.nvmeofService.deleteSubsystem(subsystem.nqn)
+        })
+    });
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.html
new file mode 100644 (file)
index 0000000..29f1e2a
--- /dev/null
@@ -0,0 +1,16 @@
+<ul class="nav nav-tabs">
+  <li class="nav-item">
+    <a class="nav-link"
+       routerLink="/block/nvmeof/subsystems"
+       routerLinkActive="active"
+       ariaCurrentWhenActive="page"
+       i18n>Subsystems</a>
+  </li>
+  <li class="nav-item">
+    <a class="nav-link"
+       routerLink="/block/nvmeof/gateways"
+       routerLinkActive="active"
+       ariaCurrentWhenActive="page"
+       i18n>Gateways</a>
+  </li>
+</ul>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.spec.ts
new file mode 100644 (file)
index 0000000..23e334a
--- /dev/null
@@ -0,0 +1,22 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { NvmeofTabsComponent } from './nvmeof-tabs.component';
+
+describe('NvmeofTabsComponent', () => {
+  let component: NvmeofTabsComponent;
+  let fixture: ComponentFixture<NvmeofTabsComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofTabsComponent]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofTabsComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-tabs/nvmeof-tabs.component.ts
new file mode 100644 (file)
index 0000000..507116c
--- /dev/null
@@ -0,0 +1,8 @@
+import { Component } from '@angular/core';
+
+@Component({
+  selector: 'cd-nvmeof-tabs',
+  templateUrl: './nvmeof-tabs.component.html',
+  styleUrls: ['./nvmeof-tabs.component.scss']
+})
+export class NvmeofTabsComponent {}
index b1d4bace97de1a879517a2b1532aebdb06190caf..8d5b8a3830c81b88fda3047d3220e7a325766dac 100644 (file)
@@ -1,5 +1,9 @@
-import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
+import { HttpClient } from '@angular/common/http';
+
+import _ from 'lodash';
+import { Observable, of as observableOf } from 'rxjs';
+import { catchError, mapTo } from 'rxjs/operators';
 
 const BASE_URL = 'api/nvmeof';
 
@@ -12,4 +16,32 @@ export class NvmeofService {
   listGateways() {
     return this.http.get(`${BASE_URL}/gateway`);
   }
+
+  listSubsystems() {
+    return this.http.get(`${BASE_URL}/subsystem`);
+  }
+
+  getSubsystem(subsystemNQN: string) {
+    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}`);
+  }
+
+  createSubsystem(request: { nqn: string; max_namespaces?: number; enable_ha: boolean }) {
+    return this.http.post(`${BASE_URL}/subsystem`, request, { observe: 'response' });
+  }
+
+  deleteSubsystem(subsystemNQN: string) {
+    return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}`, {
+      observe: 'response'
+    });
+  }
+
+  isSubsystemPresent(subsystemNqn: string): Observable<boolean> {
+    return this.getSubsystem(subsystemNqn).pipe(
+      mapTo(true),
+      catchError((e) => {
+        e?.preventDefault();
+        return observableOf(false);
+      })
+    );
+  }
 }
index f2a92ea9fb5940f904fceaad35ec05e2b533622e..e383d4a1dfca2cf4b6a07b34f9831fa83cd81acf 100644 (file)
@@ -8,3 +8,14 @@ export interface NvmeofGateway {
   load_balancing_group: string;
   spdk_version: string;
 }
+
+export interface NvmeofSubsystem {
+  nqn: string;
+  serial_number: string;
+  model_number: string;
+  min_cntlid: number;
+  max_cntlid: number;
+  namespace_count: number;
+  subtype: string;
+  max_namespaces: number;
+}
index 9aa9f02af6163785c9f0114335c1e6b7105ec84b..01bc19cb362f5f5cc9d8339a868f78b391e8e5ed 100644 (file)
@@ -328,6 +328,13 @@ export class TaskMessageService {
     'iscsi/target/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
       this.iscsiTarget(metadata)
     ),
+    // NVME/TCP tasks
+    'nvmeof/subsystem/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
+      this.nvmeofSubsystem(metadata)
+    ),
+    'nvmeof/subsystem/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+      this.nvmeofSubsystem(metadata)
+    ),
     'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.nfs(metadata)
     ),
@@ -453,6 +460,10 @@ export class TaskMessageService {
     return $localize`target '${metadata.target_iqn}'`;
   }
 
+  nvmeofSubsystem(metadata: any) {
+    return $localize`subsystem '${metadata.nqn}'`;
+  }
+
   nfs(metadata: any) {
     return $localize`NFS '${metadata.cluster_id}\:${
       metadata.export_id ? metadata.export_id : metadata.path