From de23df56d1047055db2c3602893160513a42ca52 Mon Sep 17 00:00:00 2001
From: Sagar Gopale
Date: Mon, 23 Mar 2026 11:38:44 +0530
Subject: [PATCH] mgr/dashboard: Add Hosts via CSV Upload
Fixes: https://tracker.ceph.com/issues/75578
Signed-off-by: Sagar Gopale
---
.../src/app/ceph/block/block.module.ts | 2 +
.../nvmeof-subsystem-step-2.component.html | 31 ++++++-
.../nvmeof-subsystem-step-2.component.scss | 27 +++++-
.../nvmeof-subsystem-step-2.component.spec.ts | 87 ++++++++++++++++++-
.../nvmeof-subsystem-step-2.component.ts | 87 ++++++++++++++++++-
.../nvmeof-subsystems-form.component.spec.ts | 3 +-
.../shared/services/file-uploader.service.ts | 56 ++++++++++++
7 files changed, 287 insertions(+), 6 deletions(-)
create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/services/file-uploader.service.ts
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
index c575ca7d01b9..085ae2f04ded 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/block.module.ts
@@ -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,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html
index b979d1c0fd51..a733709627f6 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html
@@ -60,7 +60,7 @@
class="form-item">
Add hosts
+ i18n>Add host manually
} @else {
{
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.'
+ );
+ });
+ });
});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts
index f90b0fa27576..70cfbd75a312 100644
--- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts
+++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts
@@ -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();
- 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