]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add initiators add/update in dashboard 58534/head
authorAfreen Misbah <afreen23.git@gmail.com>
Wed, 10 Jul 2024 11:24:20 +0000 (16:54 +0530)
committerAfreen Misbah <afreen23.git@gmail.com>
Fri, 26 Jul 2024 09:11:57 +0000 (14:41 +0530)
Fixes  https://tracker.ceph.com/issues/66907

- add one or more initiators
- remove one or more initiators
- introduces two new UI routers for the above two

Signed-off-by: Afreen Misbah <afreen23.git@gmail.com>
22 files changed:
src/pybind/mgr/dashboard/controllers/nvmeof.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-form/nvmeof-listeners-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-listeners-list/nvmeof-listeners-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-details/nvmeof-subsystems-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems/nvmeof-subsystems.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/nvmeof.service.spec.ts
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 84d7a37952e7265812ab052ffcd56e7ea26bf446..e050ecdf6a6c08a5856e371d766ecaf67da5a276 100644 (file)
@@ -7,8 +7,9 @@ from ..model import nvmeof as model
 from ..security import Scope
 from ..services.orchestrator import OrchClient
 from ..tools import str_to_bool
-from . import APIDoc, APIRouter, BaseController, Endpoint, EndpointDoc, Param, \
-    ReadPermission, RESTController, UIRouter
+from . import APIDoc, APIRouter, BaseController, CreatePermission, \
+    DeletePermission, Endpoint, EndpointDoc, Param, ReadPermission, \
+    RESTController, UIRouter
 
 logger = logging.getLogger(__name__)
 
@@ -392,23 +393,65 @@ else:
                 NVMeoFClient.pb2.list_connections_req(subsystem=nqn)
             )
 
+    @UIRouter('/nvmeof', Scope.NVME_OF)
+    class NVMeoFTcpUI(BaseController):
+        @Endpoint('GET', '/status')
+        @ReadPermission
+        @EndpointDoc("Display NVMe/TCP service status",
+                     responses={200: NVME_SCHEMA})
+        def status(self) -> dict:
+            status: Dict[str, Any] = {'available': True, 'message': None}
+            orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
+            if orch_backend == 'cephadm':
+                orch = OrchClient.instance()
+                orch_status = orch.status()
+                if not orch_status['available']:
+                    return status
+                if not orch.services.list_daemons(daemon_type='nvmeof'):
+                    status["available"] = False
+                    status["message"] = 'An NVMe/TCP service must be created.'
+            return status
+
+        @Endpoint('POST', "/subsystem/{subsystem_nqn}/host")
+        @EndpointDoc("Add one or more initiator hosts to an NVMeoF subsystem",
+                     parameters={
+                         'subsystem_nqn': (str, 'Subsystem NQN'),
+                         "host_nqn": Param(str, 'Comma separated list of NVMeoF host NQNs'),
+                     })
+        @empty_response
+        @handle_nvmeof_error
+        @CreatePermission
+        def add(self, subsystem_nqn: str, host_nqn: str = ""):
+            response = None
+            all_host_nqns = host_nqn.split(',')
+
+            for nqn in all_host_nqns:
+                response = NVMeoFClient().stub.add_host(
+                    NVMeoFClient.pb2.add_host_req(subsystem_nqn=subsystem_nqn, host_nqn=nqn)
+                )
+                if response.status != 0:
+                    return response
+            return response
 
-@UIRouter('/nvmeof', Scope.NVME_OF)
-@APIDoc("NVMe/TCP Management API", "NVMe/TCP")
-class NVMeoFStatus(BaseController):
-    @Endpoint()
-    @ReadPermission
-    @EndpointDoc("Display NVMe/TCP service Status",
-                 responses={200: NVME_SCHEMA})
-    def status(self) -> dict:
-        status: Dict[str, Any] = {'available': True, 'message': None}
-        orch_backend = mgr.get_module_option_ex('orchestrator', 'orchestrator')
-        if orch_backend == 'cephadm':
-            orch = OrchClient.instance()
-            orch_status = orch.status()
-            if not orch_status['available']:
-                return status
-            if not orch.services.list_daemons(daemon_type='nvmeof'):
-                status["available"] = False
-                status["message"] = 'Create an NVMe/TCP service to get started.'
-        return status
+        @Endpoint(method='DELETE', path="/subsystem/{subsystem_nqn}/host/{host_nqn}")
+        @EndpointDoc("Remove on or more initiator hosts from an NVMeoF subsystem",
+                     parameters={
+                         "subsystem_nqn": Param(str, "NVMeoF subsystem NQN"),
+                         "host_nqn": Param(str, 'Comma separated list of NVMeoF host NQN.'),
+                     })
+        @empty_response
+        @handle_nvmeof_error
+        @DeletePermission
+        def remove(self, subsystem_nqn: str, host_nqn: str):
+            response = None
+            to_delete_nqns = host_nqn.split(',')
+
+            for del_nqn in to_delete_nqns:
+                response = NVMeoFClient().stub.remove_host(
+                    NVMeoFClient.pb2.remove_host_req(subsystem_nqn=subsystem_nqn, host_nqn=del_nqn)
+                )
+                if response.status != 0:
+                    return response
+                logger.info("removed host %s from subsystem %s", del_nqn, subsystem_nqn)
+
+            return response
index dec04b46387a32ed7b4163b4a82aa148d88e8da1..8d377ff24511d6ae6826e1a050940132623e1da9 100644 (file)
@@ -47,6 +47,8 @@ import { NvmeofListenersFormComponent } from './nvmeof-listeners-form/nvmeof-lis
 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';
+import { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list/nvmeof-initiators-list.component';
+import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-initiators-form.component';
 
 @NgModule({
   imports: [
@@ -95,7 +97,9 @@ import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form/nvmeof-n
     NvmeofListenersFormComponent,
     NvmeofListenersListComponent,
     NvmeofNamespacesListComponent,
-    NvmeofNamespacesFormComponent
+    NvmeofNamespacesFormComponent,
+    NvmeofInitiatorsListComponent,
+    NvmeofInitiatorsFormComponent
   ],
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
@@ -249,15 +253,11 @@ const routes: Routes = [
             component: NvmeofSubsystemsFormComponent,
             outlet: 'modal'
           },
-          {
-            path: `${URLVerbs.EDIT}/:subsystem_nqn/:max_ns`,
-            component: NvmeofSubsystemsFormComponent,
-            outlet: 'modal'
-          },
           // listeners
           {
             path: `${URLVerbs.CREATE}/:subsystem_nqn/listener`,
-            component: NvmeofListenersFormComponent
+            component: NvmeofListenersFormComponent,
+            outlet: 'modal'
           },
           // namespaces
           {
@@ -269,6 +269,12 @@ const routes: Routes = [
             path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
             component: NvmeofNamespacesFormComponent,
             outlet: 'modal'
+          },
+          // initiators
+          {
+            path: `${URLVerbs.ADD}/:subsystem_nqn/initiator`,
+            component: NvmeofInitiatorsFormComponent,
+            outlet: 'modal'
           }
         ]
       },
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.html
new file mode 100644 (file)
index 0000000..a0a61d7
--- /dev/null
@@ -0,0 +1,104 @@
+<cd-modal [pageURL]="pageURL">
+  <span class="modal-title"
+        i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+  <ng-container class="modal-content">
+    <form name="initiatorForm"
+          #formDir="ngForm"
+          [formGroup]="initiatorForm"
+          novalidate>
+      <div class="modal-body">
+        <!-- Hosts -->
+        <div class="form-group row">
+          <label class="cd-col-form-label required"
+                 i18n>Hosts
+          </label>
+          <div class="cd-col-form-input">
+            <!-- Add host -->
+            <div class="custom-control custom-checkbox"
+                 formGroupName="addHost">
+              <input type="checkbox"
+                     class="custom-control-input"
+                     id="addHostCheck"
+                     name="addHostCheck"
+                     formControlName="addHostCheck"
+                     (change)="setAddHostCheck()"/>
+              <label class="custom-control-label mb-0"
+                     for="addHostCheck"
+                     i18n>Add host</label>
+              <cd-help-text>
+                <span i18n>Allow specific hosts to run NVMe/TCP commands to the NVMe subsystem.</span>
+              </cd-help-text>
+              <div formArrayName="addedHosts"
+                   *ngIf="initiatorForm.get('addHost.addHostCheck').value"  >
+                <div *ngFor="let host of addedHosts.controls; let hi = index"
+                     class="input-group cd-mb my-1">
+                  <input class="cd-form-control"
+                         type="text"
+                         i18n-placeholder
+                         placeholder="Add host nqn"
+                         [required]="!initiatorForm.getValue('allowAnyHost')"
+                         [formControlName]="hi"/>
+                  <button class="btn btn-light"
+                          type="button"
+                          id="add-button-{{hi}}"
+                          [disabled]="initiatorForm.get('addHost.addedHosts').controls[hi].invalid
+                          || initiatorForm.get('addHost.addedHosts').errors?.duplicate
+                          || initiatorForm.get('addHost.addedHosts').controls.length === 32
+                          || (initiatorForm.get('addHost.addedHosts').controls.length !== 1 && initiatorForm.get('addHost.addedHosts').controls.length !== hi+1)"
+                          (click)="addHost()">
+                    <i class="fa fa-plus"></i>
+                  </button>
+                  <button class="btn btn-light"
+                          type="button"
+                          id="delete-button-{{hi}}"
+                          [disabled]="addedHosts.controls.length === 1"
+                          (click)="removeHost(hi)">
+                    <i class="fa fa-trash-o"></i>
+                  </button>
+                  <ng-container *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].invalid
+                                && (initiatorForm.get('addHost.addedHosts').controls[hi].dirty
+                                || initiatorForm.get('addHost.addedHosts').controls[hi].touched)">
+                    <span class="invalid-feedback"
+                          *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].errors?.required"
+                          i18n>This field is required.</span>
+                    <span class="invalid-feedback"
+                          *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].errors?.pattern"
+                          i18n>Expected NQN format<br/>&lt;<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".&gt; or <br/>&lt;<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</code>".&gt;</span>
+                    <span class="invalid-feedback"
+                          *ngIf="initiatorForm.get('addHost.addedHosts').controls[hi].errors?.maxLength"
+                          i18n>An NQN may not be more than 223 bytes in length.</span>
+                  </ng-container>
+                </div>
+                <span class="invalid-feedback"
+                      *ngIf="initiatorForm.get('addHost.addedHosts').errors?.duplicate"
+                      i18n>Duplicate entry detected. Enter a unique value.</span>
+              </div>
+            </div>
+            <!-- Allow any host -->
+            <div class="custom-control custom-checkbox pt-0">
+              <input type="checkbox"
+                     class="custom-control-input"
+                     id="allowAnyHost"
+                     name="allowAnyHost"
+                     formControlName="allowAnyHost"/>
+              <label class="custom-control-label"
+                     for="allowAnyHost"
+                     i18n>Allow any host</label>
+              <cd-alert-panel *ngIf="initiatorForm.getValue('allowAnyHost')"
+                              [showTitle]="false"
+                              type="warning">Allowing any host to connect to the NVMe/TCP gateway may pose security risks.
+              </cd-alert-panel>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <div class="text-right">
+          <cd-form-button-panel (submitActionEvent)="onSubmit()"
+                                [form]="initiatorForm"
+                                [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-initiators-form/nvmeof-initiators-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.spec.ts
new file mode 100644 (file)
index 0000000..f6da04f
--- /dev/null
@@ -0,0 +1,61 @@
+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 { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form.component';
+
+describe('NvmeofInitiatorsFormComponent', () => {
+  let component: NvmeofInitiatorsFormComponent;
+  let fixture: ComponentFixture<NvmeofInitiatorsFormComponent>;
+  let nvmeofService: NvmeofService;
+  const mockTimestamp = 1720693470789;
+
+  beforeEach(async () => {
+    spyOn(Date, 'now').and.returnValue(mockTimestamp);
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofInitiatorsFormComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgbTypeaheadModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule,
+        ToastrModule.forRoot()
+      ]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofInitiatorsFormComponent);
+    component = fixture.componentInstance;
+    component.ngOnInit();
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('should test form', () => {
+    beforeEach(() => {
+      nvmeofService = TestBed.inject(NvmeofService);
+      spyOn(nvmeofService, 'addInitiators').and.stub();
+    });
+
+    it('should be creating request correctly', () => {
+      const subsystemNQN = 'nqn.2001-07.com.ceph:' + mockTimestamp;
+      component.subsystemNQN = subsystemNQN;
+      component.onSubmit();
+      expect(nvmeofService.addInitiators).toHaveBeenCalledWith(subsystemNQN, {
+        host_nqn: ''
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-form/nvmeof-initiators-form.component.ts
new file mode 100644 (file)
index 0000000..3a143a1
--- /dev/null
@@ -0,0 +1,135 @@
+import { Component, OnInit } from '@angular/core';
+import { UntypedFormArray, UntypedFormControl, Validators } from '@angular/forms';
+
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+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 { ActivatedRoute, Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+@Component({
+  selector: 'cd-nvmeof-initiators-form',
+  templateUrl: './nvmeof-initiators-form.component.html',
+  styleUrls: ['./nvmeof-initiators-form.component.scss']
+})
+export class NvmeofInitiatorsFormComponent implements OnInit {
+  permission: Permission;
+  initiatorForm: CdFormGroup;
+  action: string;
+  resource: string;
+  pageURL: string;
+  remove: boolean = false;
+  subsystemNQN: string;
+  removeHosts: { name: string; value: boolean; id: number }[] = [];
+
+  constructor(
+    private authStorageService: AuthStorageService,
+    public actionLabels: ActionLabelsI18n,
+    private nvmeofService: NvmeofService,
+    private taskWrapperService: TaskWrapperService,
+    private router: Router,
+    private route: ActivatedRoute,
+    private formBuilder: CdFormBuilder
+  ) {
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+    this.resource = $localize`Initiator`;
+    this.pageURL = 'block/nvmeof/subsystems';
+  }
+
+  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}$/;
+  ALLOW_ALL_HOST = '*';
+
+  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.ADD;
+    this.route.params.subscribe((params: { subsystem_nqn: string }) => {
+      this.subsystemNQN = params.subsystem_nqn;
+    });
+  }
+
+  createForm() {
+    this.initiatorForm = new CdFormGroup({
+      allowAnyHost: new UntypedFormControl(false),
+      addHost: new CdFormGroup({
+        addHostCheck: new UntypedFormControl(false),
+        addedHosts: this.formBuilder.array(
+          [],
+          [
+            CdValidators.custom(
+              'duplicate',
+              (hosts: string[]) => !!hosts.length && new Set(hosts)?.size !== hosts.length
+            )
+          ]
+        )
+      })
+    });
+  }
+
+  get addedHosts(): UntypedFormArray {
+    return this.initiatorForm.get('addHost.addedHosts') as UntypedFormArray;
+  }
+
+  addHost() {
+    let newHostFormGroup;
+    newHostFormGroup = this.formBuilder.control('', [this.customNQNValidator, Validators.required]);
+    this.addedHosts.push(newHostFormGroup);
+  }
+
+  removeHost(index: number) {
+    this.addedHosts.removeAt(index);
+  }
+
+  setAddHostCheck() {
+    const addHostCheck = this.initiatorForm.get('addHost.addHostCheck').value;
+    if (!addHostCheck) {
+      while (this.addedHosts.length !== 0) {
+        this.addedHosts.removeAt(0);
+      }
+    } else {
+      this.addHost();
+    }
+  }
+
+  onSubmit() {
+    const component = this;
+    const allowAnyHost: boolean = this.initiatorForm.getValue('allowAnyHost');
+    const hosts: string[] = this.addedHosts.value;
+    let taskUrl = `nvmeof/initiator/${URLVerbs.ADD}`;
+
+    const request = {
+      host_nqn: hosts.join(',')
+    };
+
+    if (allowAnyHost) {
+      hosts.push('*');
+      request['host_nqn'] = hosts.join(',');
+    }
+    this.taskWrapperService
+      .wrapTaskAroundCall({
+        task: new FinishedTask(taskUrl, {
+          nqn: this.subsystemNQN
+        }),
+        call: this.nvmeofService.addInitiators(this.subsystemNQN, request)
+      })
+      .subscribe({
+        error() {
+          component.initiatorForm.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-initiators-list/nvmeof-initiators-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.html
new file mode 100644 (file)
index 0000000..29ebbe6
--- /dev/null
@@ -0,0 +1,27 @@
+<legend>
+  <cd-help-text>
+      The client that connects to the NVMe-oF target to access NVMe storage.
+  </cd-help-text>
+</legend>
+<cd-table [data]="initiators"
+          columnMode="flex"
+          (fetchData)="listInitiators()"
+          [columns]="initiatorColumns"
+          selectionType="multiClick"
+          (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>
+<ng-template #hostTpl
+             let-value="value">
+  <span *ngIf="value === '*'"
+        i18n
+        class="font-monospace">Any host allowed (*)</span>
+  <span *ngIf="value !== '*'"
+        class="font-monospace">{{value}}</span>
+</ng-template>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.spec.ts
new file mode 100644 (file)
index 0000000..f8d9c67
--- /dev/null
@@ -0,0 +1,68 @@
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { HttpClientModule } from '@angular/common/http';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { of } from 'rxjs';
+
+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 { NvmeofInitiatorsListComponent } from './nvmeof-initiators-list.component';
+
+const mockInitiators = [
+  {
+    nqn: '*'
+  }
+];
+
+class MockNvmeOfService {
+  getInitiators() {
+    return of(mockInitiators);
+  }
+}
+
+class MockAuthStorageService {
+  getPermissions() {
+    return { nvmeof: {} };
+  }
+}
+
+class MockModalService {}
+
+class MockTaskWrapperService {}
+
+describe('NvmeofInitiatorsListComponent', () => {
+  let component: NvmeofInitiatorsListComponent;
+  let fixture: ComponentFixture<NvmeofInitiatorsListComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofInitiatorsListComponent],
+      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(NvmeofInitiatorsListComponent);
+    component = fixture.componentInstance;
+    component.ngOnInit();
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should retrieve initiators', fakeAsync(() => {
+    component.listInitiators();
+    tick();
+    expect(component.initiators).toEqual(mockInitiators);
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-initiators-list/nvmeof-initiators-list.component.ts
new file mode 100644 (file)
index 0000000..2491ccc
--- /dev/null
@@ -0,0 +1,125 @@
+import { Component, Input, OnChanges, OnInit, TemplateRef, ViewChild } 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 { NvmeofSubsystemInitiator } 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-initiators-list',
+  templateUrl: './nvmeof-initiators-list.component.html',
+  styleUrls: ['./nvmeof-initiators-list.component.scss']
+})
+export class NvmeofInitiatorsListComponent implements OnInit, OnChanges {
+  @Input()
+  subsystemNQN: string;
+
+  @ViewChild('hostTpl', { static: true })
+  hostTpl: TemplateRef<any>;
+
+  initiatorColumns: any;
+  tableActions: CdTableAction[];
+  selection = new CdTableSelection();
+  permission: Permission;
+  initiators: NvmeofSubsystemInitiator[] = [];
+
+  constructor(
+    public actionLabels: ActionLabelsI18n,
+    private authStorageService: AuthStorageService,
+    private nvmeofService: NvmeofService,
+    private modalService: ModalService,
+    private router: Router,
+    private taskWrapper: TaskWrapperService
+  ) {
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+  }
+
+  ngOnInit() {
+    this.initiatorColumns = [
+      {
+        name: $localize`Initiator`,
+        prop: 'nqn',
+        cellTemplate: this.hostTpl
+      }
+    ];
+    this.tableActions = [
+      {
+        name: this.actionLabels.ADD,
+        permission: 'create',
+        icon: Icons.add,
+        click: () =>
+          this.router.navigate([
+            BASE_URL,
+            { outlets: { modal: [URLVerbs.ADD, this.subsystemNQN, 'initiator'] } }
+          ]),
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+      },
+      {
+        name: this.actionLabels.REMOVE,
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.removeInitiatorModal(),
+        disable: () => !this.selection.hasSelection,
+        canBePrimary: (selection: CdTableSelection) => selection.hasSelection
+      }
+    ];
+  }
+
+  getAllowAllHostIndex() {
+    return this.selection.selected.findIndex((selected) => selected.nqn === '*');
+  }
+
+  ngOnChanges() {
+    this.listInitiators();
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  listInitiators() {
+    this.nvmeofService
+      .getInitiators(this.subsystemNQN)
+      .subscribe((initiators: NvmeofSubsystemInitiator[]) => {
+        this.initiators = initiators;
+      });
+  }
+
+  getSelectedNQNs() {
+    return this.selection.selected.map((selected) => selected.nqn);
+  }
+
+  removeInitiatorModal() {
+    const hostNQNs = this.getSelectedNQNs();
+    const allowAllHostIndex = this.getAllowAllHostIndex();
+    const host_nqn = hostNQNs.join(',');
+    let itemNames = hostNQNs;
+    if (allowAllHostIndex !== -1) {
+      hostNQNs.splice(allowAllHostIndex, 1);
+      itemNames = [...hostNQNs, $localize`Allow any host(*)`];
+    }
+    this.modalService.show(CriticalConfirmationModalComponent, {
+      itemDescription: 'Initiator',
+      itemNames,
+      actionDescription: 'remove',
+      submitActionObservable: () =>
+        this.taskWrapper.wrapTaskAroundCall({
+          task: new FinishedTask('nvmeof/initiator/remove', {
+            nqn: this.subsystemNQN,
+            plural: itemNames.length > 1
+          }),
+          call: this.nvmeofService.removeInitiators(this.subsystemNQN, { host_nqn })
+        })
+    });
+  }
+}
index b115fd5b6f6fb997e7d241717ec40bdf150da72b..74bad35b13cdb05d379e820f042c96d8c4bd841c 100644 (file)
@@ -7,13 +7,11 @@ 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({
@@ -38,11 +36,4 @@ describe('NvmeofListenersFormComponent', () => {
   it('should create', () => {
     expect(component).toBeTruthy();
   });
-
-  describe('should test form', () => {
-    beforeEach(() => {
-      nvmeofService = TestBed.inject(NvmeofService);
-      spyOn(nvmeofService, 'createListener').and.stub();
-    });
-  });
 });
index ecf8c4959af2e0f0019283c1ca0d814ce50fe40e..01a436022fa660457e932c836b7e275fd7dcbe31 100644 (file)
@@ -62,7 +62,7 @@ describe('NvmeofListenersListComponent', () => {
     expect(component).toBeTruthy();
   });
 
-  it('should retrieve subsystems', fakeAsync(() => {
+  it('should retrieve listeners', fakeAsync(() => {
     component.listListeners();
     tick();
     expect(component.listeners).toEqual(mockListeners);
index f1e222bfd71497edb2b6668ae4fbb32b889cbecc..72576b7e6426d03212417a43adf61a8eabaa9126 100644 (file)
               </span>
               <span class="invalid-feedback"
                     *ngIf="edit && invalidSizeError">
-                <ng-container i18n>Enter a value above than previous.</ng-container>
+                <ng-container i18n>Enter a value above than previous. A block device image can be expanded but not reduced.</ng-container>
               </span>
             </div>
           </div>
index e9001805ddcdad2a4475d8a24e485459ab299f1d..3749d47bccfa018bd6d1cdd38bad9081a2fe11f4 100644 (file)
         <cd-nvmeof-namespaces-list [subsystemNQN]="subsystemNQN"></cd-nvmeof-namespaces-list>
       </ng-template>
     </ng-container>
+    <ng-container ngbNavItem="initiators">
+      <a ngbNavLink
+         i18n>Initiators</a>
+      <ng-template ngbNavContent>
+        <cd-nvmeof-initiators-list [subsystemNQN]="subsystemNQN"></cd-nvmeof-initiators-list>
+      </ng-template>
+    </ng-container>
   </nav>
 
   <div [ngbNavOutlet]="nav"></div>
index 5e8abf9a4852f1475e1aa76e60def2f87d4cd686..211905f285fbfd78a87ffd303741bfabf68003f2 100644 (file)
@@ -18,6 +18,7 @@ export class NvmeofSubsystemsDetailsComponent implements OnChanges {
     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;
index a12846e5dac8ac756fddcf2d8592b04c800408dd..1032a0d1e26f2a09df65537220828513ed6eef00 100644 (file)
@@ -34,7 +34,7 @@
                   i18n>Expected NQN format<br/>&lt;<code>nqn.$year-$month.$reverseDomainName:$utf8-string</code>".&gt; or <br/>&lt;<code>nqn.2014-08.org.nvmexpress:uuid:$UUID-string</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>
+                  i18n>An NQN may not be more than 223 bytes in length.</span>
           </div>
         </div>
         <!-- Maximum Namespaces -->
index 775aed08c67facd95f1536a388d2cb2df77de166..5debb52c4d3abb565c94f99d24c9a06ca6e80b0f 100644 (file)
@@ -20,7 +20,6 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 export class NvmeofSubsystemsFormComponent implements OnInit {
   permission: Permission;
   subsystemForm: CdFormGroup;
-
   action: string;
   resource: string;
   pageURL: string;
@@ -59,7 +58,7 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
         validators: [
           this.customNQNValidator,
           Validators.required,
-          Validators.pattern(this.NQN_REGEX),
+          this.customNQNValidator,
           CdValidators.custom(
             'maxLength',
             (nqnInput: string) => new TextEncoder().encode(nqnInput).length > 223
@@ -78,7 +77,8 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
   onSubmit() {
     const component = this;
     const nqn: string = this.subsystemForm.getValue('nqn');
-    let max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces'));
+    const max_namespaces: number = Number(this.subsystemForm.getValue('max_namespaces'));
+    let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
 
     const request = {
       nqn,
@@ -89,9 +89,6 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
     if (!max_namespaces) {
       delete request.max_namespaces;
     }
-
-    let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`;
-
     this.taskWrapperService
       .wrapTaskAroundCall({
         task: new FinishedTask(taskUrl, {
index 8c4b3cbd26e9ae81e09351c4d134317cd99d17f4..8626dfc2ef0eb09f1d538bae34ab0e96968fefd1 100644 (file)
@@ -65,6 +65,24 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
         click: () => this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]),
         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.selection.first().nqn,
+                  this.selection.first().max_namespaces
+                ]
+              }
+            }
+          ])
+      },
       {
         name: this.actionLabels.DELETE,
         permission: 'delete',
index d021906f46b35439f3144bb067d689b67f8d54d6..bbc38b9ce16d5505b546e568abc57c7aabdb3745 100644 (file)
@@ -55,12 +55,4 @@ describe('NvmeofService', () => {
     );
     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');
-  });
 });
index 063693de61da80c4f4da96e0bdc93e03a4f06fa0..4b4c4e86693ca77d8476d6772e959d581659ee12 100644 (file)
@@ -21,7 +21,12 @@ export interface NamespaceEditRequest {
   rbd_image_size: number;
 }
 
-const BASE_URL = 'api/nvmeof';
+export interface InitiatorRequest {
+  host_nqn: string;
+}
+
+const API_PATH = 'api/nvmeof';
+const UI_API_PATH = 'ui-api/nvmeof';
 
 @Injectable({
   providedIn: 'root'
@@ -31,24 +36,24 @@ export class NvmeofService {
 
   // Gateways
   listGateways() {
-    return this.http.get(`${BASE_URL}/gateway`);
+    return this.http.get(`${API_PATH}/gateway`);
   }
 
   // Subsystems
   listSubsystems() {
-    return this.http.get(`${BASE_URL}/subsystem`);
+    return this.http.get(`${API_PATH}/subsystem`);
   }
 
   getSubsystem(subsystemNQN: string) {
-    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}`);
+    return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}`);
   }
 
   createSubsystem(request: { nqn: string; max_namespaces?: number; enable_ha: boolean }) {
-    return this.http.post(`${BASE_URL}/subsystem`, request, { observe: 'response' });
+    return this.http.post(`${API_PATH}/subsystem`, request, { observe: 'response' });
   }
 
   deleteSubsystem(subsystemNQN: string) {
-    return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}`, {
+    return this.http.delete(`${API_PATH}/subsystem/${subsystemNQN}`, {
       observe: 'response'
     });
   }
@@ -65,33 +70,35 @@ export class NvmeofService {
 
   // Initiators
   getInitiators(subsystemNQN: string) {
-    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/host`);
+    return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/host`);
   }
 
-  updateInitiators(subsystemNQN: string, hostNQN: string) {
-    return this.http.put(
-      `${BASE_URL}/subsystem/${subsystemNQN}/host/${hostNQN}`,
-      {},
-      {
-        observe: 'response'
-      }
-    );
+  addInitiators(subsystemNQN: string, request: InitiatorRequest) {
+    return this.http.post(`${UI_API_PATH}/subsystem/${subsystemNQN}/host`, request, {
+      observe: 'response'
+    });
+  }
+
+  removeInitiators(subsystemNQN: string, request: InitiatorRequest) {
+    return this.http.delete(`${UI_API_PATH}/subsystem/${subsystemNQN}/host/${request.host_nqn}`, {
+      observe: 'response'
+    });
   }
 
   // Listeners
   listListeners(subsystemNQN: string) {
-    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/listener`);
+    return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/listener`);
   }
 
   createListener(subsystemNQN: string, request: ListenerRequest) {
-    return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/listener`, request, {
+    return this.http.post(`${API_PATH}/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}`,
+      `${API_PATH}/subsystem/${subsystemNQN}/listener/${hostName}/${traddr}`,
       {
         observe: 'response',
         params: {
@@ -103,27 +110,27 @@ export class NvmeofService {
 
   // Namespaces
   listNamespaces(subsystemNQN: string) {
-    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`);
+    return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/namespace`);
   }
 
   getNamespace(subsystemNQN: string, nsid: string) {
-    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`);
+    return this.http.get(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`);
   }
 
   createNamespace(subsystemNQN: string, request: NamespaceCreateRequest) {
-    return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`, request, {
+    return this.http.post(`${API_PATH}/subsystem/${subsystemNQN}/namespace`, request, {
       observe: 'response'
     });
   }
 
   updateNamespace(subsystemNQN: string, nsid: string, request: NamespaceEditRequest) {
-    return this.http.patch(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, {
+    return this.http.patch(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, {
       observe: 'response'
     });
   }
 
   deleteNamespace(subsystemNQN: string, nsid: string) {
-    return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, {
+    return this.http.delete(`${API_PATH}/subsystem/${subsystemNQN}/namespace/${nsid}`, {
       observe: 'response'
     });
   }
index 21969db73c83a76a868ea5455cdc183f879dc620..5d60923d00448fad3fa74626091fa70ed70ee230 100644 (file)
@@ -20,6 +20,10 @@ export interface NvmeofSubsystem {
   max_namespaces: number;
 }
 
+export interface NvmeofSubsystemInitiator {
+  nqn: string;
+}
+
 export interface NvmeofListener {
   host_name: string;
   trtype: string;
@@ -29,10 +33,6 @@ export interface NvmeofListener {
   id?: number; // for table
 }
 
-export interface NvmeofSubsystemHost {
-  nqn: string;
-}
-
 export interface NvmeofSubsystemNamespace {
   nsid: number;
   uuid: string;
index eedb05e306655c05f9b03af608d286b55d1a5cba..db91422e8a308ba6e07b4790c939ffea19c682fa 100644 (file)
@@ -373,9 +373,6 @@ export class TaskMessageService {
     '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)
     ),
@@ -385,6 +382,12 @@ export class TaskMessageService {
     'nvmeof/namespace/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
       this.nvmeofNamespace(metadata)
     ),
+    'nvmeof/initiator/add': this.newTaskMessage(this.commonOperations.add, (metadata) =>
+      this.nvmeofInitiator(metadata)
+    ),
+    'nvmeof/initiator/remove': this.newTaskMessage(this.commonOperations.remove, (metadata) =>
+      this.nvmeofInitiator(metadata)
+    ),
     // nfs
     'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.nfs(metadata)
@@ -516,7 +519,7 @@ export class TaskMessageService {
   }
 
   nvmeofListener(metadata: any) {
-    return $localize`listener '${metadata.host_name} on subsystem ${metadata.nqn}`;
+    return $localize`listener '${metadata.host_name} for subsystem ${metadata.nqn}`;
   }
 
   nvmeofNamespace(metadata: any) {
@@ -526,6 +529,10 @@ export class TaskMessageService {
     return $localize`namespace for subsystem '${metadata.nqn}'`;
   }
 
+  nvmeofInitiator(metadata: any) {
+    return $localize`initiator${metadata?.plural ? 's' : ''} for subsystem ${metadata.nqn}`;
+  }
+
   nfs(metadata: any) {
     return $localize`NFS '${metadata.cluster_id}\:${
       metadata.export_id ? metadata.export_id : metadata.path