]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph-ci.git/commitdiff
mgr/dashboard: Add Hosts via CSV Upload
authorSagar Gopale <sagar.gopale@ibm.com>
Mon, 23 Mar 2026 06:08:44 +0000 (11:38 +0530)
committerSagar Gopale <sagar.gopale@ibm.com>
Mon, 25 May 2026 08:40:24 +0000 (14:10 +0530)
Fixes: https://tracker.ceph.com/issues/75578
Signed-off-by: Sagar Gopale <sagar.gopale@ibm.com>
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts
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/shared/services/file-uploader.service.ts [new file with mode: 0644]

index c575ca7d01b9a5e4d18e153366e02bc8da9e8642..085ae2f04ded8eff546515cc2cae466fd2e597dd 100644 (file)
@@ -61,6 +61,7 @@ import {
   CheckboxModule,
   ComboBoxModule,
   DatePickerModule,
+  FileUploaderModule,
   GridModule,
   IconModule,
   IconService,
@@ -127,6 +128,7 @@ import { NvmeofTabsComponent } from './nvmeof-tabs/nvmeof-tabs.component';
     NumberModule,
     ModalModule,
     DatePickerModule,
+    FileUploaderModule,
     ComboBoxModule,
     TabsModule,
     TagModule,
index b979d1c0fd5197dabd68004829986264367c9107..a733709627f6f2d4690efc11e9e4bc5c052653ab 100644 (file)
@@ -60,7 +60,7 @@
            class="form-item">
         <h1
           class="cds--type-heading-compact-01"
-          i18n>Add hosts</h1>
+          i18n>Add host manually</h1>
         <label class="cds--label"
                for="hostname">
           <span i18n>Host name</span>
           </button>
         </div>
       </div>
+      <div cdsRow
+           class="form-item">
+        <h1
+          class="cds--type-heading-compact-01"
+          i18n>Upload CSV file</h1>
+        <p
+          class="cds--type-body-01 cds-mb-3 cd-nvmeof-subsystem-step-two-added-hosts-text"
+          i18n>Upload a CSV file containing a list of host names.</p>
+        <div class="cd-nvmeof-subsystem-step-two-csv-container">
+          <cds-file-uploader
+            [drop]="true"
+            [dropText]="csvDropText"
+            [accept]="['csv']"
+            [multiple]="false"
+            size="md"
+            class="cd-nvmeof-subsystem-step-two-csv-input"
+            (filesChange)="onCsvUpload($event)"
+            (removeFile)="onCsvUploadRemove()"></cds-file-uploader>
+          @if(csvUploadError) {
+          <cd-alert-panel
+            type="danger"
+            [showTitle]="false"
+            size="slim"
+            class="cds-mt-3 cd-nvmeof-subsystem-step-two-csv-error-panel">{{ csvUploadError }}</cd-alert-panel>
+          }
+        </div>
+      </div>
       }
     </div>
   </div>
     i18n>Added hosts ({{addedHostsLength}})</h1>
   <p
     i18n
-    class="cds--type-body-01 cd-nvmeof-subsystem-step-two-added-hosts-text">No hosts added yet.</p>
+    class="cds--type-body-01 cd-nvmeof-subsystem-step-two-added-hosts-text">No hosts added yet. Add hosts manually or upload a CSV file.</p>
   } @else {
   <cds-contained-list
     label="Added hosts ({{addedHostsLength}})"
index 36fe955b7e9f1d605ac825067603e95d69319ec6..3d614e49bf2088b77af2b7633dedb0d68cb98e40 100644 (file)
@@ -1,16 +1,21 @@
+@use '@carbon/layout';
+
 .cd-nvmeof-subsystem-step-two {
   // Full width of the tearsheet content column; min-inline-size allows nested
   // Carbon grid rows to shrink instead of bleeding into the right influencer.
   min-inline-size: 0;
   inline-size: 100%;
 
+  // Aligns with exactly 10 columns of the standard 16-column Carbon grid layout (10/16).
+  --host-input-width: #{percentage(10 / 16)};
+
   &-manual-hosts {
     display: flex;
     align-items: flex-start;
   }
 
   &-manual-hosts-input {
-    flex: 0.5;
+    flex: 0 0 var(--host-input-width);
     margin-right: var(--cds-spacing-05);
   }
 
     max-inline-size: 17rem;
   }
 
+  &-csv-container {
+    inline-size: var(--host-input-width);
+  }
+
+  &-csv-input {
+    .cds--file__drop-container {
+      min-block-size: layout.$spacing-12 !important;
+    }
+  }
+
+  &-csv-error-panel {
+    display: block;
+    width: 100%;
+
+    .cds--inline-notification,
+    .cds--actionable-notification {
+      width: 100%;
+    }
+  }
+
   &-influencer {
     .cds--contained-list-item__content {
       max-inline-size: 18rem !important;
index 11fc45c4eaf3003e664a6d36d72f120381b8f888..ee04e2af07e173cd89efd6691d3106e1b9d3ab8d 100644 (file)
@@ -8,7 +8,13 @@ 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 { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2.component';
-import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular';
+import {
+  FileUploaderModule,
+  GridModule,
+  InputModule,
+  RadioModule,
+  TagModule
+} from 'carbon-components-angular';
 
 describe('NvmeofSubsystemsStepTwoComponent', () => {
   let component: NvmeofSubsystemsStepTwoComponent;
@@ -26,6 +32,7 @@ describe('NvmeofSubsystemsStepTwoComponent', () => {
         ReactiveFormsModule,
         RouterTestingModule,
         SharedModule,
+        FileUploaderModule,
         InputModule,
         GridModule,
         RadioModule,
@@ -111,4 +118,82 @@ describe('NvmeofSubsystemsStepTwoComponent', () => {
       expect(component.addedHostsLength).toBe(0);
     });
   });
+
+  describe('processCsvContent', () => {
+    it('should import valid host NQNs from CSV', () => {
+      const csvContent = `
+host_nqn
+nqn.2023-01.com.example:host1
+nqn.2023-01.com.example:host2
+`;
+
+      component.processCsvContent(csvContent);
+
+      expect(form.get('addedHosts')?.value).toEqual([
+        'nqn.2023-01.com.example:host1',
+        'nqn.2023-01.com.example:host2'
+      ]);
+      expect(component.addedHostsLength).toBe(2);
+      expect(component.csvUploadError).toBe('');
+    });
+
+    it('should skip duplicates and invalid values from CSV', () => {
+      form.get('addedHosts')?.setValue(['nqn.2023-01.com.example:existing']);
+      component.existingHosts = ['nqn.2023-01.com.example:already-there'];
+
+      const csvContent = `
+host_nqn
+nqn.2023-01.com.example:existing
+nqn.2023-01.com.example:new-host
+invalid-host
+nqn.2023-01.com.example:already-there
+nqn.2023-01.com.example:new-host
+`;
+
+      component.processCsvContent(csvContent);
+
+      expect(form.get('addedHosts')?.value).toEqual([
+        'nqn.2023-01.com.example:existing',
+        'nqn.2023-01.com.example:new-host'
+      ]);
+      expect(component.addedHostsLength).toBe(2);
+      expect(component.csvUploadError).toBe('');
+    });
+
+    it('should show error when csv has no valid hosts', () => {
+      const csvContent = `
+host_nqn
+invalid-host
+another-invalid-host
+`;
+
+      component.processCsvContent(csvContent);
+
+      expect(form.get('addedHosts')?.value).toEqual([]);
+      expect(component.csvUploadError).toBe('No valid hosts found in the CSV file.');
+    });
+
+    it('should not show invalid-csv error when all uploaded hosts are duplicates', () => {
+      form
+        .get('addedHosts')
+        ?.setValue(['nqn.2023-01.com.example:host1', 'nqn.2023-01.com.example:host2']);
+      component.addedHostsLength = 2;
+
+      const csvContent = `
+host_nqn
+nqn.2023-01.com.example:host1
+nqn.2023-01.com.example:host2
+`;
+
+      component.processCsvContent(csvContent);
+
+      expect(form.get('addedHosts')?.value).toEqual([
+        'nqn.2023-01.com.example:host1',
+        'nqn.2023-01.com.example:host2'
+      ]);
+      expect(component.csvUploadError).toBe(
+        'No new hosts were found. Provided hosts are already in place.'
+      );
+    });
+  });
 });
index f90b0fa275769a116d01de801d934a1dae41725f..70cfbd75a31239c870972ed990baccbaec9857bf 100644 (file)
@@ -2,6 +2,8 @@ import { Component, Input, OnInit, TemplateRef, ViewChild, ViewEncapsulation } f
 import { FormControl, UntypedFormControl } from '@angular/forms';
 import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap';
 
+import { FileUploaderService } from '~/app/shared/services/file-uploader.service';
+
 import { ActionLabelsI18n } from '~/app/shared/constants/app.constants';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
 import { CdValidators } from '~/app/shared/forms/cd-validators';
@@ -30,11 +32,18 @@ export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep {
   };
   HOST_TYPE = HOST_TYPE;
   addedHostsLength: number = 0;
+  csvUploadError = '';
+  csvDropText: string = $localize`Drag and drop files here or click to upload`;
   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 = '*';
+  uploadedHosts = new Set<string>();
 
-  constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {}
+  constructor(
+    public actionLabels: ActionLabelsI18n,
+    public activeModal: NgbActiveModal,
+    private fileUploaderService: FileUploaderService
+  ) {}
 
   ngOnInit() {
     this.createForm();
@@ -100,6 +109,82 @@ export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep {
     }
   }
 
+  onCsvUpload(files: Set<Object>) {
+    this.fileUploaderService.readAsText(files, { accept: ['.csv'] }).subscribe({
+      next: ({ content }) => this.processCsvContent(content),
+      error: (err: Error) => (this.csvUploadError = err.message)
+    });
+  }
+
+  onCsvUploadRemove() {
+    this.csvUploadError = '';
+    if (this.uploadedHosts.size > 0) {
+      const currentAddedHosts = this.formGroup.get('addedHosts')?.value ?? [];
+      const newHostList = currentAddedHosts.filter((host: string) => !this.uploadedHosts.has(host));
+      this.addedHostsLength = newHostList.length;
+      this.formGroup.patchValue({ addedHosts: newHostList });
+      this.formGroup.get('hostname')?.updateValueAndValidity();
+      this.uploadedHosts.clear();
+    }
+  }
+
+  processCsvContent(csvContent: string) {
+    const lines = csvContent.split(/\r?\n/);
+    const currentAddedHosts = this.formGroup.get('addedHosts').value || [];
+    const currentHostsSet = new Set<string>([...currentAddedHosts, ...this.existingHosts]);
+    const importedHosts = new Set<string>();
+    let validHostRows = 0;
+
+    for (const line of lines) {
+      const host = this.extractHostFromCsvLine(line);
+      if (!host) {
+        continue;
+      }
+      if (!this.isHostNqnValid(host)) {
+        continue;
+      }
+      validHostRows++;
+      if (currentHostsSet.has(host) || importedHosts.has(host)) {
+        continue;
+      }
+      importedHosts.add(host);
+      this.uploadedHosts.add(host);
+    }
+
+    if (!importedHosts.size) {
+      this.csvUploadError =
+        validHostRows > 0
+          ? $localize`No new hosts were found. Provided hosts are already in place.`
+          : $localize`No valid hosts found in the CSV file.`;
+      this.formGroup.get('hostname').updateValueAndValidity();
+      return;
+    }
+
+    const newHostList = [...currentAddedHosts, ...importedHosts];
+    this.addedHostsLength = newHostList.length;
+    this.formGroup.patchValue({ addedHosts: newHostList });
+    this.formGroup.get('hostname').updateValueAndValidity();
+    this.csvUploadError = '';
+  }
+
+  private extractHostFromCsvLine(line: string): string {
+    const trimmedLine = line?.trim();
+    if (!trimmedLine) {
+      return '';
+    }
+
+    // Support both "host_nqn" header and "host_nqn,dhchap_key" style rows.
+    const host = trimmedLine.split(',')[0]?.trim().replace(/^"|"$/g, '');
+    if (!host || host.toLowerCase() === 'host_nqn') {
+      return '';
+    }
+    return host;
+  }
+
+  private isHostNqnValid(host: string): boolean {
+    return this.NQN_REGEX.test(host) || this.NQN_REGEX_UUID.test(host);
+  }
+
   removeHost(removedHost: string) {
     const currentAddedHosts = this.formGroup.get('addedHosts')?.value ?? [];
     const newHostList = currentAddedHosts.filter((currentHost) => currentHost !== removedHost);
index 0ea20095816a449fdfcffec54f073e7ae8135643..dc98d870f4dbcb87620bc5e677854acea59c04c0 100644 (file)
@@ -3,7 +3,6 @@ import { ReactiveFormsModule } from '@angular/forms';
 import { ActivatedRoute } from '@angular/router';
 import { RouterTestingModule } from '@angular/router/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-
 import { NgbActiveModal, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap';
 
 import { SharedModule } from '~/app/shared/shared.module';
@@ -15,6 +14,7 @@ import { NvmeofService } from '~/app/shared/api/nvmeof.service';
 import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component';
 import {
   ComboBoxModule,
+  FileUploaderModule,
   GridModule,
   InputModule,
   RadioModule,
@@ -72,6 +72,7 @@ describe('NvmeofSubsystemsFormComponent', () => {
         GridModule,
         RadioModule,
         TagModule,
+        FileUploaderModule,
         ComboBoxModule
       ]
     }).compileComponents();
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/file-uploader.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/file-uploader.service.ts
new file mode 100644 (file)
index 0000000..d16c8c6
--- /dev/null
@@ -0,0 +1,56 @@
+import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+
+export interface FileReadOptions {
+  accept?: string[];
+  maxBytes?: number;
+}
+
+export interface FileReadResult {
+  content: string;
+  file: File;
+}
+
+@Injectable({ providedIn: 'root' })
+export class FileUploaderService {
+  readAsText(files: Set<any>, options: FileReadOptions = {}): Observable<FileReadResult> {
+    const { accept = [], maxBytes = 1_000_000 } = options;
+    return new Observable((observer) => {
+      const file: File = files?.values()?.next()?.value?.file;
+      if (!file) {
+        observer.error(new Error($localize`No file selected.`));
+        return undefined;
+      }
+
+      if (maxBytes && file.size > maxBytes) {
+        observer.error(new Error($localize`File exceeds the maximum size of ${maxBytes} bytes.`));
+        return undefined;
+      }
+
+      if (accept.length) {
+        const name = file.name.toLowerCase();
+        const valid = accept.some((ext) =>
+          ext.startsWith('.') ? name.endsWith(ext) : file.type === ext
+        );
+        if (!valid) {
+          observer.error(new Error($localize`Invalid file type. Accepted: ${accept.join(', ')}.`));
+          return undefined;
+        }
+      }
+
+      const reader = new FileReader();
+      reader.onload = (event: ProgressEvent<FileReader>) => {
+        const result = event.target?.result;
+        if (typeof result !== 'string') {
+          observer.error(new Error($localize`Unable to read the file.`));
+        } else {
+          observer.next({ content: result, file });
+          observer.complete();
+        }
+      };
+      reader.onerror = () => observer.error(new Error($localize`Unable to read the file.`));
+      reader.readAsText(file);
+      return () => reader.abort();
+    });
+  }
+}