]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add namespaces views in dashboard 58636/head
authorAfreen Misbah <afreen23.git@gmail.com>
Tue, 16 Jul 2024 07:03:26 +0000 (12:33 +0530)
committerAfreen Misbah <afreen23.git@gmail.com>
Thu, 18 Jul 2024 14:51:19 +0000 (20:21 +0530)
Fixes https://tracker.ceph.com/issues/66990

- list namespaces
- create namespaces
- edit namespaces
- delete namespaces
- update NQN regex

Signed-off-by: Afreen Misbah <afreen23.git@gmail.com>
21 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts [new file with mode: 0644]
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-form/nvmeof-subsystems-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.spec.ts
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/pipes/mbpersecond.pipe.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index 21a0d5483ec79287eda0e2c11b9eaad69980f3ae..dec04b46387a32ed7b4163b4a82aa148d88e8da1 100644 (file)
@@ -45,6 +45,8 @@ 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';
+import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list/nvmeof-namespaces-list.component';
+import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form/nvmeof-namespaces-form.component';
 
 @NgModule({
   imports: [
@@ -91,7 +93,9 @@ import { NvmeofListenersListComponent } from './nvmeof-listeners-list/nvmeof-lis
     NvmeofTabsComponent,
     NvmeofSubsystemsFormComponent,
     NvmeofListenersFormComponent,
-    NvmeofListenersListComponent
+    NvmeofListenersListComponent,
+    NvmeofNamespacesListComponent,
+    NvmeofNamespacesFormComponent
   ],
   exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent]
 })
@@ -238,6 +242,7 @@ const routes: Routes = [
         component: NvmeofSubsystemsComponent,
         data: { breadcrumbs: 'Subsystems' },
         children: [
+          // subsystems
           { path: '', component: NvmeofSubsystemsComponent },
           {
             path: URLVerbs.CREATE,
@@ -245,13 +250,24 @@ const routes: Routes = [
             outlet: 'modal'
           },
           {
-            path: `${URLVerbs.EDIT}/:subsystem_nqn`,
+            path: `${URLVerbs.EDIT}/:subsystem_nqn/:max_ns`,
             component: NvmeofSubsystemsFormComponent,
             outlet: 'modal'
           },
+          // listeners
           {
             path: `${URLVerbs.CREATE}/:subsystem_nqn/listener`,
-            component: NvmeofListenersFormComponent,
+            component: NvmeofListenersFormComponent
+          },
+          // namespaces
+          {
+            path: `${URLVerbs.CREATE}/:subsystem_nqn/namespace`,
+            component: NvmeofNamespacesFormComponent,
+            outlet: 'modal'
+          },
+          {
+            path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`,
+            component: NvmeofNamespacesFormComponent,
             outlet: 'modal'
           }
         ]
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.html
new file mode 100644 (file)
index 0000000..f1e222b
--- /dev/null
@@ -0,0 +1,118 @@
+<cd-modal [pageURL]="pageURL"
+          [modalRef]="activeModal">
+  <span class="modal-title"
+        i18n>{{ action | titlecase }} {{ resource | upperFirst }}</span>
+  <ng-container class="modal-content">
+    <form name="nsForm"
+          #formDir="ngForm"
+          [formGroup]="nsForm"
+          novalidate>
+      <div class="modal-body">
+        <!-- Block Pool -->
+        <div class="form-group row">
+          <label class="cd-col-form-label"
+                 for="pool">
+            <span [ngClass]="{'required': !edit}"
+                  i18n>Pool</span>
+          </label>
+          <div class="cd-col-form-input">
+            <input *ngIf="edit"
+                   name="pool"
+                   class="form-control"
+                   type="text"
+                   formControlName="pool">
+            <select *ngIf="!edit"
+                    id="pool"
+                    name="pool"
+                    class="form-select"
+                    formControlName="pool">
+              <option *ngIf="rbdPools === null"
+                      [ngValue]="null"
+                      i18n>Loading...</option>
+              <option *ngIf="rbdPools && rbdPools.length === 0"
+                      [ngValue]="null"
+                      i18n>-- No block pools available --</option>
+              <option *ngIf="rbdPools && rbdPools.length > 0"
+                      [ngValue]="null"
+                      i18n>-- Select a pool --</option>
+              <option *ngFor="let pool of rbdPools"
+                      [value]="pool.pool_name">{{ pool.pool_name }}</option>
+            </select>
+            <cd-help-text i18n>
+              A RBD application-enabled pool where the image will be created.
+            </cd-help-text>
+            <span class="invalid-feedback"
+                  *ngIf="nsForm.showError('pool', formDir, 'required')"
+                  i18n>This field is required.</span>
+          </div>
+        </div>
+        <!-- Image Name -->
+        <div class="form-group row">
+          <label class="cd-col-form-label"
+                 for="image">
+            <span [ngClass]="{'required': !edit}"
+                  i18n>Image Name</span>
+          </label>
+          <div class="cd-col-form-input">
+            <input name="image"
+                   class="form-control"
+                   type="text"
+                   formControlName="image">
+            <span class="invalid-feedback"
+                  *ngIf="nsForm.showError('image', formDir, 'required')">
+              <ng-container i18n>This field is required.</ng-container>
+            </span>
+            <span class="invalid-feedback"
+                  *ngIf="nsForm.showError('image', formDir, 'pattern')">
+              <ng-container i18n>'/' and '@' are not allowed.</ng-container>
+            </span>
+          </div>
+        </div>
+        <!-- Image Size -->
+        <div class="form-group row">
+          <label class="cd-col-form-label"
+                 for="image_size">
+            <span [ngClass]="{'required': edit}"
+                  i18n>Image Size</span>
+          </label>
+          <div class="cd-col-form-input">
+            <div class="input-group">
+              <input id="size"
+                     class="form-control"
+                     type="text"
+                     name="image_size"
+                     formControlName="image_size">
+              <select id="unit"
+                      name="unit"
+                      class="form-input form-select"
+                      formControlName="unit">
+                <option *ngFor="let u of units"
+                        [value]="u"
+                        i18n>{{ u }}</option>
+              </select>
+              <span class="invalid-feedback"
+                    *ngIf="nsForm.showError('image_size', formDir, 'pattern')">
+                <ng-container i18n>Enter a positive integer.</ng-container>
+              </span>
+              <span class="invalid-feedback"
+                    *ngIf="edit && nsForm.showError('image_size', formDir, 'required')">
+                <ng-container i18n>This field is required</ng-container>
+              </span>
+              <span class="invalid-feedback"
+                    *ngIf="edit && invalidSizeError">
+                <ng-container i18n>Enter a value above than previous.</ng-container>
+              </span>
+            </div>
+          </div>
+        </div>
+      </div>
+      <div class="modal-footer">
+        <div class="text-right">
+          <cd-form-button-panel (submitActionEvent)="onSubmit()"
+                                [form]="nsForm"
+                                [submitText]="(action | titlecase) + ' ' + (resource | upperFirst)"></cd-form-button-panel>
+        </div>
+      </div>
+    </form>
+  </ng-container>
+</cd-modal>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.spec.ts
new file mode 100644 (file)
index 0000000..b6d0c27
--- /dev/null
@@ -0,0 +1,88 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { ToastrModule } from 'ngx-toastr';
+
+import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { SharedModule } from '~/app/shared/shared.module';
+
+import { NvmeofNamespacesFormComponent } from './nvmeof-namespaces-form.component';
+import { FormHelper } from '~/testing/unit-test-helper';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+
+describe('NvmeofNamespacesFormComponent', () => {
+  let component: NvmeofNamespacesFormComponent;
+  let fixture: ComponentFixture<NvmeofNamespacesFormComponent>;
+  let nvmeofService: NvmeofService;
+  let form: CdFormGroup;
+  let formHelper: FormHelper;
+  const mockTimestamp = 1720693470789;
+  const mockSubsystemNQN = 'nqn.2021-11.com.example:subsystem';
+
+  beforeEach(async () => {
+    spyOn(Date, 'now').and.returnValue(mockTimestamp);
+    await TestBed.configureTestingModule({
+      declarations: [NvmeofNamespacesFormComponent],
+      providers: [NgbActiveModal],
+      imports: [
+        HttpClientTestingModule,
+        NgbTypeaheadModule,
+        ReactiveFormsModule,
+        RouterTestingModule,
+        SharedModule,
+        ToastrModule.forRoot()
+      ]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofNamespacesFormComponent);
+    component = fixture.componentInstance;
+    component.ngOnInit();
+    form = component.nsForm;
+    formHelper = new FormHelper(form);
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('should test form', () => {
+    beforeEach(() => {
+      component.subsystemNQN = mockSubsystemNQN;
+      nvmeofService = TestBed.inject(NvmeofService);
+      spyOn(nvmeofService, 'createNamespace').and.stub();
+    });
+
+    it('should be creating request correctly', () => {
+      const image = 'nvme_ns_image:' + mockTimestamp;
+      component.onSubmit();
+      expect(nvmeofService.createNamespace).toHaveBeenCalledWith(mockSubsystemNQN, {
+        rbd_image_name: image,
+        rbd_pool: null,
+        size: 1073741824
+      });
+    });
+
+    it('should give error on invalid image name', () => {
+      formHelper.setValue('image', '/ghfhdlk;kd;@');
+      component.onSubmit();
+      formHelper.expectError('image', 'pattern');
+    });
+
+    it('should give error on invalid image size', () => {
+      formHelper.setValue('image_size', -56);
+      component.onSubmit();
+      formHelper.expectError('image_size', 'pattern');
+    });
+
+    it('should give error on 0 image size', () => {
+      formHelper.setValue('image_size', 0);
+      component.onSubmit();
+      formHelper.expectError('image_size', 'min');
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-form/nvmeof-namespaces-form.component.ts
new file mode 100644 (file)
index 0000000..f5721e1
--- /dev/null
@@ -0,0 +1,192 @@
+import { Component, OnInit } from '@angular/core';
+import { UntypedFormControl, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
+import {
+  NamespaceCreateRequest,
+  NamespaceEditRequest,
+  NvmeofService
+} from '~/app/shared/api/nvmeof.service';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
+import { Permission } from '~/app/shared/models/permissions';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { Pool } from '../../pool/pool';
+import { PoolService } from '~/app/shared/api/pool.service';
+import { RbdService } from '~/app/shared/api/rbd.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { Observable } from 'rxjs';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+
+@Component({
+  selector: 'cd-nvmeof-namespaces-form',
+  templateUrl: './nvmeof-namespaces-form.component.html',
+  styleUrls: ['./nvmeof-namespaces-form.component.scss']
+})
+export class NvmeofNamespacesFormComponent implements OnInit {
+  action: string;
+  permission: Permission;
+  poolPermission: Permission;
+  resource: string;
+  pageURL: string;
+  edit: boolean = false;
+  nsForm: CdFormGroup;
+  subsystemNQN: string;
+  rbdPools: Array<Pool> = null;
+  units: Array<string> = ['KiB', 'MiB', 'GiB', 'TiB'];
+  nsid: string;
+  currentBytes: number;
+  invalidSizeError: boolean;
+
+  constructor(
+    public actionLabels: ActionLabelsI18n,
+    private authStorageService: AuthStorageService,
+    private taskWrapperService: TaskWrapperService,
+    private nvmeofService: NvmeofService,
+    private poolService: PoolService,
+    private rbdService: RbdService,
+    private router: Router,
+    private route: ActivatedRoute,
+    public activeModal: NgbActiveModal,
+    public formatterService: FormatterService,
+    public dimlessBinaryPipe: DimlessBinaryPipe
+  ) {
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+    this.poolPermission = this.authStorageService.getPermissions().pool;
+    this.resource = $localize`Namespace`;
+    this.pageURL = 'block/nvmeof/subsystems';
+  }
+
+  init() {
+    this.createForm();
+    this.action = this.actionLabels.CREATE;
+    this.route.params.subscribe((params: { subsystem_nqn: string; nsid: string }) => {
+      this.subsystemNQN = params.subsystem_nqn;
+      this.nsid = params?.nsid;
+    });
+  }
+
+  initForEdit() {
+    this.edit = true;
+    this.action = this.actionLabels.EDIT;
+    this.nvmeofService
+      .getNamespace(this.subsystemNQN, this.nsid)
+      .subscribe((res: NvmeofSubsystemNamespace) => {
+        const convertedSize = this.dimlessBinaryPipe.transform(res.rbd_image_size).split(' ');
+        this.currentBytes = res.rbd_image_size;
+        this.nsForm.get('image').setValue(res.rbd_image_name);
+        this.nsForm.get('pool').setValue(res.rbd_pool_name);
+        this.nsForm.get('unit').setValue(convertedSize[1]);
+        this.nsForm.get('image_size').setValue(convertedSize[0]);
+        this.nsForm.get('image_size').addValidators(Validators.required);
+        this.nsForm.get('image').disable();
+        this.nsForm.get('pool').disable();
+      });
+  }
+
+  initForCreate() {
+    this.poolService.getList().subscribe((resp: Pool[]) => {
+      this.rbdPools = resp.filter(this.rbdService.isRBDPool);
+    });
+  }
+
+  ngOnInit() {
+    this.init();
+    if (this.router.url.includes('subsystems/(modal:edit')) {
+      this.initForEdit();
+    } else {
+      this.initForCreate();
+    }
+  }
+
+  createForm() {
+    this.nsForm = new CdFormGroup({
+      image: new UntypedFormControl(`nvme_ns_image:${Date.now()}`, {
+        validators: [Validators.required, Validators.pattern(/^[^@/]+?$/)]
+      }),
+      pool: new UntypedFormControl(null, {
+        validators: [Validators.required]
+      }),
+      image_size: new UntypedFormControl(1, [CdValidators.number(false), Validators.min(1)]),
+      unit: new UntypedFormControl(this.units[2])
+    });
+  }
+
+  buildRequest(): NamespaceCreateRequest | NamespaceEditRequest {
+    const image_size = this.nsForm.getValue('image_size');
+    const image_size_unit = this.nsForm.getValue('unit');
+    const request = {} as NamespaceCreateRequest | NamespaceEditRequest;
+    if (image_size) {
+      const key: string = this.edit ? 'rbd_image_size' : 'size';
+      const value: number = this.formatterService.toBytes(image_size + image_size_unit);
+      request[key] = value;
+    }
+    if (!this.edit) {
+      const image = this.nsForm.getValue('image');
+      const pool = this.nsForm.getValue('pool');
+      request['rbd_image_name'] = image;
+      request['rbd_pool'] = pool;
+    }
+    return request;
+  }
+
+  validateSize() {
+    const unit = this.nsForm.getValue('unit');
+    const image_size = this.nsForm.getValue('image_size');
+    if (image_size && unit) {
+      const bytes = this.formatterService.toBytes(image_size + unit);
+      return bytes <= this.currentBytes;
+    }
+    return null;
+  }
+
+  onSubmit() {
+    if (this.validateSize()) {
+      this.invalidSizeError = true;
+      this.nsForm.setErrors({ cdSubmitButton: true });
+    } else {
+      this.invalidSizeError = false;
+      const component = this;
+      const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`;
+      const request = this.buildRequest();
+      let action: Observable<any>;
+
+      if (this.edit) {
+        action = this.taskWrapperService.wrapTaskAroundCall({
+          task: new FinishedTask(taskUrl, {
+            nqn: this.subsystemNQN,
+            nsid: this.nsid
+          }),
+          call: this.nvmeofService.updateNamespace(
+            this.subsystemNQN,
+            this.nsid,
+            request as NamespaceEditRequest
+          )
+        });
+      } else {
+        action = this.taskWrapperService.wrapTaskAroundCall({
+          task: new FinishedTask(taskUrl, {
+            nqn: this.subsystemNQN
+          }),
+          call: this.nvmeofService.createNamespace(
+            this.subsystemNQN,
+            request as NamespaceCreateRequest
+          )
+        });
+      }
+
+      action.subscribe({
+        error() {
+          component.nsForm.setErrors({ cdSubmitButton: true });
+        },
+        complete: () => {
+          this.router.navigate([this.pageURL, { outlets: { modal: null } }]);
+        }
+      });
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.html
new file mode 100644 (file)
index 0000000..bebe170
--- /dev/null
@@ -0,0 +1,20 @@
+<legend>
+  <cd-help-text>
+    An NVMe namespace is a quantity of non-volatile storage that can be formatted into logical blocks and presented to a host as a standard block device.
+  </cd-help-text>
+</legend>
+<cd-table [data]="namespaces"
+          columnMode="flex"
+          (fetchData)="listNamespaces()"
+          [columns]="namespacesColumns"
+          selectionType="single"
+          (updateSelection)="updateSelection($event)">
+
+  <div class="table-actions btn-toolbar">
+    <cd-table-actions [permission]="permission"
+                      [selection]="selection"
+                      class="btn-group"
+                      [tableActions]="tableActions">
+    </cd-table-actions>
+  </div>
+</cd-table>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.spec.ts
new file mode 100644 (file)
index 0000000..7556262
--- /dev/null
@@ -0,0 +1,84 @@
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { HttpClientModule } from '@angular/common/http';
+import { of } from 'rxjs';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+
+import { NvmeofService } from '../../../shared/api/nvmeof.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { NvmeofTabsComponent } from '../nvmeof-tabs/nvmeof-tabs.component';
+import { NvmeofSubsystemsDetailsComponent } from '../nvmeof-subsystems-details/nvmeof-subsystems-details.component';
+import { NvmeofNamespacesListComponent } from './nvmeof-namespaces-list.component';
+
+const mockNamespaces = [
+  {
+    nsid: 1,
+    uuid: 'f4396245-186f-401a-b71c-945ccf0f0cc9',
+    bdev_name: 'bdev_f4396245-186f-401a-b71c-945ccf0f0cc9',
+    rbd_image_name: 'string',
+    rbd_pool_name: 'rbd',
+    load_balancing_group: 1,
+    rbd_image_size: 1024,
+    block_size: 512,
+    rw_ios_per_second: 0,
+    rw_mbytes_per_second: 0,
+    r_mbytes_per_second: 0,
+    w_mbytes_per_second: 0
+  }
+];
+
+class MockNvmeOfService {
+  listNamespaces() {
+    return of(mockNamespaces);
+  }
+}
+
+class MockAuthStorageService {
+  getPermissions() {
+    return { nvmeof: {} };
+  }
+}
+
+class MockModalService {}
+
+class MockTaskWrapperService {}
+
+describe('NvmeofNamespacesListComponent', () => {
+  let component: NvmeofNamespacesListComponent;
+  let fixture: ComponentFixture<NvmeofNamespacesListComponent>;
+
+  beforeEach(async () => {
+    await TestBed.configureTestingModule({
+      declarations: [
+        NvmeofNamespacesListComponent,
+        NvmeofTabsComponent,
+        NvmeofSubsystemsDetailsComponent
+      ],
+      imports: [HttpClientModule, RouterTestingModule, SharedModule],
+      providers: [
+        { provide: NvmeofService, useClass: MockNvmeOfService },
+        { provide: AuthStorageService, useClass: MockAuthStorageService },
+        { provide: ModalService, useClass: MockModalService },
+        { provide: TaskWrapperService, useClass: MockTaskWrapperService }
+      ]
+    }).compileComponents();
+
+    fixture = TestBed.createComponent(NvmeofNamespacesListComponent);
+    component = fixture.componentInstance;
+    component.ngOnInit();
+    component.subsystemNQN = 'nqn.2001-07.com.ceph:1721040751436';
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should retrieve namespaces', fakeAsync(() => {
+    component.listNamespaces();
+    tick();
+    expect(component.namespaces).toEqual(mockNamespaces);
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespaces-list/nvmeof-namespaces-list.component.ts
new file mode 100644 (file)
index 0000000..cbf9639
--- /dev/null
@@ -0,0 +1,181 @@
+import { Component, Input, OnChanges, OnInit } from '@angular/core';
+import { Router } from '@angular/router';
+import { NvmeofService } from '~/app/shared/api/nvmeof.service';
+import { CriticalConfirmationModalComponent } from '~/app/shared/components/critical-confirmation-modal/critical-confirmation-modal.component';
+import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants';
+import { Icons } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { NvmeofSubsystemNamespace } from '~/app/shared/models/nvmeof';
+import { Permission } from '~/app/shared/models/permissions';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
+import { IopsPipe } from '~/app/shared/pipes/iops.pipe';
+import { MbpersecondPipe } from '~/app/shared/pipes/mbpersecond.pipe';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalService } from '~/app/shared/services/modal.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+
+const BASE_URL = 'block/nvmeof/subsystems';
+
+@Component({
+  selector: 'cd-nvmeof-namespaces-list',
+  templateUrl: './nvmeof-namespaces-list.component.html',
+  styleUrls: ['./nvmeof-namespaces-list.component.scss']
+})
+export class NvmeofNamespacesListComponent implements OnInit, OnChanges {
+  @Input()
+  subsystemNQN: string;
+
+  namespacesColumns: any;
+  tableActions: CdTableAction[];
+  selection = new CdTableSelection();
+  permission: Permission;
+  namespaces: NvmeofSubsystemNamespace[];
+
+  constructor(
+    public actionLabels: ActionLabelsI18n,
+    private router: Router,
+    private modalService: ModalService,
+    private authStorageService: AuthStorageService,
+    private taskWrapper: TaskWrapperService,
+    private nvmeofService: NvmeofService,
+    private dimlessBinaryPipe: DimlessBinaryPipe,
+    private mbPerSecondPipe: MbpersecondPipe,
+    private iopsPipe: IopsPipe
+  ) {
+    this.permission = this.authStorageService.getPermissions().nvmeof;
+  }
+
+  ngOnInit() {
+    this.namespacesColumns = [
+      {
+        name: $localize`ID`,
+        prop: 'nsid'
+      },
+      {
+        name: $localize`Bdev Name`,
+        prop: 'bdev_name'
+      },
+      {
+        name: $localize`Pool `,
+        prop: 'rbd_pool_name',
+        flexGrow: 2
+      },
+      {
+        name: $localize`Image`,
+        prop: 'rbd_image_name',
+        flexGrow: 3
+      },
+      {
+        name: $localize`Image Size`,
+        prop: 'rbd_image_size',
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: $localize`Block Size`,
+        prop: 'block_size',
+        pipe: this.dimlessBinaryPipe
+      },
+      {
+        name: $localize`IOPS`,
+        prop: 'rw_ios_per_second',
+        sortable: false,
+        pipe: this.iopsPipe,
+        flexGrow: 1.5
+      },
+      {
+        name: $localize`R/W Throughput`,
+        prop: 'rw_mbytes_per_second',
+        sortable: false,
+        pipe: this.mbPerSecondPipe,
+        flexGrow: 1.5
+      },
+      {
+        name: $localize`Read Throughput`,
+        prop: 'r_mbytes_per_second',
+        sortable: false,
+        pipe: this.mbPerSecondPipe,
+        flexGrow: 1.5
+      },
+      {
+        name: $localize`Write Throughput`,
+        prop: 'w_mbytes_per_second',
+        sortable: false,
+        pipe: this.mbPerSecondPipe,
+        flexGrow: 1.5
+      },
+      {
+        name: $localize`Load Balancing Group`,
+        prop: 'load_balancing_group',
+        flexGrow: 1.5
+      }
+    ];
+    this.tableActions = [
+      {
+        name: this.actionLabels.CREATE,
+        permission: 'create',
+        icon: Icons.add,
+        click: () =>
+          this.router.navigate([
+            BASE_URL,
+            { outlets: { modal: [URLVerbs.CREATE, this.subsystemNQN, 'namespace'] } }
+          ]),
+        canBePrimary: (selection: CdTableSelection) => !selection.hasSelection
+      },
+      {
+        name: this.actionLabels.EDIT,
+        permission: 'update',
+        icon: Icons.edit,
+        click: () =>
+          this.router.navigate([
+            BASE_URL,
+            {
+              outlets: {
+                modal: [URLVerbs.EDIT, this.subsystemNQN, 'namespace', this.selection.first().nsid]
+              }
+            }
+          ])
+      },
+      {
+        name: this.actionLabels.DELETE,
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.deleteSubsystemModal()
+      }
+    ];
+  }
+
+  ngOnChanges() {
+    this.listNamespaces();
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+  }
+
+  listNamespaces() {
+    this.nvmeofService
+      .listNamespaces(this.subsystemNQN)
+      .subscribe((res: NvmeofSubsystemNamespace[]) => {
+        this.namespaces = res;
+      });
+  }
+
+  deleteSubsystemModal() {
+    const namespace = this.selection.first();
+    this.modalService.show(CriticalConfirmationModalComponent, {
+      itemDescription: 'Namespace',
+      itemNames: [namespace.nsid],
+      actionDescription: 'delete',
+      submitActionObservable: () =>
+        this.taskWrapper.wrapTaskAroundCall({
+          task: new FinishedTask('nvmeof/namespace/delete', {
+            nqn: this.subsystemNQN,
+            nsid: namespace.nsid
+          }),
+          call: this.nvmeofService.deleteNamespace(this.subsystemNQN, namespace.nsid)
+        })
+    });
+  }
+}
index 5b551bdb96611346ec36e8bf519f70ba741487a4..e9001805ddcdad2a4475d8a24e485459ab299f1d 100644 (file)
         </cd-nvmeof-listeners-list>
       </ng-template>
     </ng-container>
+    <ng-container ngbNavItem="namespaces">
+      <a ngbNavLink
+         i18n>Namespaces</a>
+      <ng-template ngbNavContent>
+        <cd-nvmeof-namespaces-list [subsystemNQN]="subsystemNQN"></cd-nvmeof-namespaces-list>
+      </ng-template>
+    </ng-container>
   </nav>
 
   <div [ngbNavOutlet]="nav"></div>
index 93e27094e17ff7f31ab260f05063e252293accfe..a12846e5dac8ac756fddcf2d8592b04c800408dd 100644 (file)
@@ -21,7 +21,7 @@
                    type="text"
                    formControlName="nqn">
               <cd-help-text>
-                The NVMe Qualified Name (NQN) is a unique and permanent name for the lifetime of the subsystem.
+                A unique and permanent name for the lifetime of the subsystem.
               </cd-help-text>
             <span class="invalid-feedback"
                   *ngIf="subsystemForm.showError('nqn', formDir, 'required')"
@@ -31,7 +31,7 @@
                   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>
+                  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>
index bfa642de1cb97a54cfaf4d86db8e54ea335aac01..4e9c420c9f0e4097325e7e3caece9c27c050156d 100644 (file)
@@ -22,6 +22,7 @@ describe('NvmeofSubsystemsFormComponent', () => {
   const mockTimestamp = 1720693470789;
 
   beforeEach(async () => {
+    spyOn(Date, 'now').and.returnValue(mockTimestamp);
     await TestBed.configureTestingModule({
       declarations: [NvmeofSubsystemsFormComponent],
       providers: [NgbActiveModal],
@@ -40,7 +41,6 @@ describe('NvmeofSubsystemsFormComponent', () => {
     component.ngOnInit();
     form = component.subsystemForm;
     formHelper = new FormHelper(form);
-    spyOn(Date, 'now').and.returnValue(mockTimestamp);
     fixture.detectChanges();
   });
 
index 2af218af54a0a448497805ef38a6bf9926536159..775aed08c67facd95f1536a388d2cb2df77de166 100644 (file)
@@ -25,8 +25,6 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
   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,
@@ -40,6 +38,16 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
     this.pageURL = 'block/nvmeof/subsystems';
   }
 
+  DEFAULT_NQN = 'nqn.2001-07.com.ceph:' + Date.now();
+  NQN_REGEX = /^nqn\.(19|20)\d\d-(0[1-9]|1[0-2])\.\D{2,3}(\.[A-Za-z0-9-]+)+(:[A-Za-z0-9-\.]+(:[A-Za-z0-9-\.]+)*)$/;
+  NQN_REGEX_UUID = /^nqn\.2014-08\.org\.nvmexpress:uuid:[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/;
+
+  customNQNValidator = CdValidators.custom(
+    'pattern',
+    (nqnInput: string) =>
+      !!nqnInput && !(this.NQN_REGEX.test(nqnInput) || this.NQN_REGEX_UUID.test(nqnInput))
+  );
+
   ngOnInit() {
     this.createForm();
     this.action = this.actionLabels.CREATE;
@@ -47,8 +55,9 @@ export class NvmeofSubsystemsFormComponent implements OnInit {
 
   createForm() {
     this.subsystemForm = new CdFormGroup({
-      nqn: new UntypedFormControl('nqn.2001-07.com.ceph:' + Date.now(), {
+      nqn: new UntypedFormControl(this.DEFAULT_NQN, {
         validators: [
+          this.customNQNValidator,
           Validators.required,
           Validators.pattern(this.NQN_REGEX),
           CdValidators.custom(
index d281901627b9ea5baa81e3e7e6af05b1590fc997..8c4b3cbd26e9ae81e09351c4d134317cd99d17f4 100644 (file)
@@ -53,7 +53,7 @@ export class NvmeofSubsystemsComponent extends ListWithDetails implements OnInit
         prop: 'namespace_count'
       },
       {
-        name: $localize`# Maximum Namespaces`,
+        name: $localize`# Maximum Allowed Namespaces`,
         prop: 'max_namespaces'
       }
     ];
index dd6aba7cf6c28b99bb0dca71d61aa18d3bb4df68..d021906f46b35439f3144bb067d689b67f8d54d6 100644 (file)
@@ -30,4 +30,37 @@ describe('NvmeofService', () => {
     const req = httpTesting.expectOne('api/nvmeof/gateway');
     expect(req.request.method).toBe('GET');
   });
+
+  it('should call getSubsystem', () => {
+    service.getSubsystem('nqn.2001-07.com.ceph:1721041732363').subscribe();
+    const req = httpTesting.expectOne('api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363');
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call createSubsystem', () => {
+    const request = {
+      nqn: 'nqn.2001-07.com.ceph:1721041732363',
+      enable_ha: true,
+      initiators: '*'
+    };
+    service.createSubsystem(request).subscribe();
+    const req = httpTesting.expectOne('api/nvmeof/subsystem');
+    expect(req.request.method).toBe('POST');
+  });
+
+  it('should call getInitiators', () => {
+    service.getInitiators('nqn.2001-07.com.ceph:1721041732363').subscribe();
+    const req = httpTesting.expectOne(
+      'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host'
+    );
+    expect(req.request.method).toBe('GET');
+  });
+
+  it('should call updateInitiators', () => {
+    service.updateInitiators('nqn.2001-07.com.ceph:1721041732363', '*').subscribe();
+    const req = httpTesting.expectOne(
+      'api/nvmeof/subsystem/nqn.2001-07.com.ceph:1721041732363/host/*'
+    );
+    expect(req.request.method).toBe('PUT');
+  });
 });
index d9375ba0de3c39b7d26905b5570f67d4b35019b8..063693de61da80c4f4da96e0bdc93e03a4f06fa0 100644 (file)
@@ -11,6 +11,16 @@ export interface ListenerRequest {
   trsvcid: number;
 }
 
+export interface NamespaceCreateRequest {
+  rbd_image_name: string;
+  rbd_pool: string;
+  size: number;
+}
+
+export interface NamespaceEditRequest {
+  rbd_image_size: number;
+}
+
 const BASE_URL = 'api/nvmeof';
 
 @Injectable({
@@ -19,10 +29,12 @@ const BASE_URL = 'api/nvmeof';
 export class NvmeofService {
   constructor(private http: HttpClient) {}
 
+  // Gateways
   listGateways() {
     return this.http.get(`${BASE_URL}/gateway`);
   }
 
+  // Subsystems
   listSubsystems() {
     return this.http.get(`${BASE_URL}/subsystem`);
   }
@@ -51,7 +63,22 @@ export class NvmeofService {
     );
   }
 
-  // listeners
+  // Initiators
+  getInitiators(subsystemNQN: string) {
+    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/host`);
+  }
+
+  updateInitiators(subsystemNQN: string, hostNQN: string) {
+    return this.http.put(
+      `${BASE_URL}/subsystem/${subsystemNQN}/host/${hostNQN}`,
+      {},
+      {
+        observe: 'response'
+      }
+    );
+  }
+
+  // Listeners
   listListeners(subsystemNQN: string) {
     return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/listener`);
   }
@@ -73,4 +100,31 @@ export class NvmeofService {
       }
     );
   }
+
+  // Namespaces
+  listNamespaces(subsystemNQN: string) {
+    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`);
+  }
+
+  getNamespace(subsystemNQN: string, nsid: string) {
+    return this.http.get(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`);
+  }
+
+  createNamespace(subsystemNQN: string, request: NamespaceCreateRequest) {
+    return this.http.post(`${BASE_URL}/subsystem/${subsystemNQN}/namespace`, request, {
+      observe: 'response'
+    });
+  }
+
+  updateNamespace(subsystemNQN: string, nsid: string, request: NamespaceEditRequest) {
+    return this.http.patch(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, request, {
+      observe: 'response'
+    });
+  }
+
+  deleteNamespace(subsystemNQN: string, nsid: string) {
+    return this.http.delete(`${BASE_URL}/subsystem/${subsystemNQN}/namespace/${nsid}`, {
+      observe: 'response'
+    });
+  }
 }
index e86dae7dcd91af28ca704c5daad6b069b611641b..21969db73c83a76a868ea5455cdc183f879dc620 100644 (file)
@@ -28,3 +28,22 @@ export interface NvmeofListener {
   trsvcid: number; // 4420
   id?: number; // for table
 }
+
+export interface NvmeofSubsystemHost {
+  nqn: string;
+}
+
+export interface NvmeofSubsystemNamespace {
+  nsid: number;
+  uuid: string;
+  bdev_name: string;
+  rbd_image_name: string;
+  rbd_pool_name: string;
+  load_balancing_group: number;
+  rbd_image_size: number;
+  block_size: number;
+  rw_ios_per_second: number;
+  rw_mbytes_per_second: number;
+  r_mbytes_per_second: number;
+  w_mbytes_per_second: number;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.spec.ts
new file mode 100644 (file)
index 0000000..d21cbe1
--- /dev/null
@@ -0,0 +1,8 @@
+import { MbpersecondPipe } from './mbpersecond.pipe';
+
+describe('MbpersecondPipe', () => {
+  it('create an instance', () => {
+    const pipe = new MbpersecondPipe();
+    expect(pipe).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/mbpersecond.pipe.ts
new file mode 100644 (file)
index 0000000..1a007fa
--- /dev/null
@@ -0,0 +1,10 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+@Pipe({
+  name: 'mbpersecond'
+})
+export class MbpersecondPipe implements PipeTransform {
+  transform(value: any): any {
+    return `${value} MB/s`;
+  }
+}
index 025eb72a47b8dbbe6ef11ed6181ed9b3b708226d..fadc21e40c99d39c6f7b818a68a7cccdadfad8f0 100755 (executable)
@@ -39,6 +39,7 @@ import { OctalToHumanReadablePipe } from './octal-to-human-readable.pipe';
 import { PathPipe } from './path.pipe';
 import { PluralizePipe } from './pluralize.pipe';
 import { XmlPipe } from './xml.pipe';
+import { MbpersecondPipe } from './mbpersecond.pipe';
 
 @NgModule({
   imports: [CommonModule],
@@ -80,7 +81,8 @@ import { XmlPipe } from './xml.pipe';
     OctalToHumanReadablePipe,
     PathPipe,
     PluralizePipe,
-    XmlPipe
+    XmlPipe,
+    MbpersecondPipe
   ],
   exports: [
     ArrayPipe,
@@ -120,7 +122,8 @@ import { XmlPipe } from './xml.pipe';
     OctalToHumanReadablePipe,
     PathPipe,
     PluralizePipe,
-    XmlPipe
+    XmlPipe,
+    MbpersecondPipe
   ],
   providers: [
     ArrayPipe,
@@ -152,7 +155,8 @@ import { XmlPipe } from './xml.pipe';
     MgrSummaryPipe,
     MdsSummaryPipe,
     OsdSummaryPipe,
-    OctalToHumanReadablePipe
+    OctalToHumanReadablePipe,
+    MbpersecondPipe
   ]
 })
 export class PipesModule {}
index 4ae4a5bc028ae52d308b993c4dcf2b50e85f654e..8e615327114bdf4b12dff10105643c230e252661 100644 (file)
@@ -367,6 +367,18 @@ 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)
+    ),
+    'nvmeof/namespace/edit': this.newTaskMessage(this.commonOperations.update, (metadata) =>
+      this.nvmeofNamespace(metadata)
+    ),
+    'nvmeof/namespace/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
+      this.nvmeofNamespace(metadata)
+    ),
     // nfs
     'nfs/create': this.newTaskMessage(this.commonOperations.create, (metadata) =>
       this.nfs(metadata)
@@ -501,6 +513,13 @@ export class TaskMessageService {
     return $localize`listener '${metadata.host_name} on subsystem ${metadata.nqn}`;
   }
 
+  nvmeofNamespace(metadata: any) {
+    if (metadata?.nsid) {
+      return $localize`namespace ${metadata.nsid} for subsystem '${metadata.nqn}'`;
+    }
+    return $localize`namespace for subsystem '${metadata.nqn}'`;
+  }
+
   nfs(metadata: any) {
     return $localize`NFS '${metadata.cluster_id}\:${
       metadata.export_id ? metadata.export_id : metadata.path