From 98d14c47a67d54bd27f6a96bdd569d2db7db4287 Mon Sep 17 00:00:00 2001 From: Sagar Gopale Date: Thu, 12 Feb 2026 18:12:34 +0530 Subject: [PATCH] mgr/dashboard: Nvmeof edit namespace size Fixes: https://tracker.ceph.com/issues/74900 Signed-off-by: Sagar Gopale --- .../src/app/ceph/block/block.module.ts | 25 +- .../nvmeof-gateway.component.html | 1 + ...meof-namespace-expand-modal.component.html | 64 +++ ...meof-namespace-expand-modal.component.scss | 0 ...f-namespace-expand-modal.component.spec.ts | 94 ++++ ...nvmeof-namespace-expand-modal.component.ts | 142 ++++++ .../nvmeof-namespaces-form.component.html | 133 ++---- .../nvmeof-namespaces-form.component.spec.ts | 87 +--- .../nvmeof-namespaces-form.component.ts | 414 +++++++----------- .../nvmeof-namespaces-list.component.ts | 24 +- .../src/app/shared/enum/icons.enum.ts | 1 + .../src/app/shared/forms/cd-validators.ts | 16 + 12 files changed, 562 insertions(+), 439 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.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 c995622d357..edb4a1ec7d3 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 @@ -88,7 +88,9 @@ import Reset from '@carbon/icons/es/reset/32'; import SubtractAlt from '@carbon/icons/es/subtract--alt/20'; import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32'; import Search from '@carbon/icons/es/search/32'; +import Datastore from '@carbon/icons/es/datastore/16'; import { NvmeofGatewaySubsystemComponent } from './nvmeof-gateway-subsystem/nvmeof-gateway-subsystem.component'; +import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component'; import { NvmeGatewayViewComponent } from './nvme-gateway-view/nvme-gateway-view.component'; import { NvmeGatewayViewBreadcrumbResolver } from './nvme-gateway-view/nvme-gateway-view-breadcrumb.resolver'; import { NvmeofGatewayNodeMode } from '~/app/shared/enum/nvmeof.enum'; @@ -176,6 +178,7 @@ import { NvmeSubsystemViewComponent } from './nvme-subsystem-view/nvme-subsystem NvmeGatewayViewComponent, NvmeofGatewaySubsystemComponent, NvmeofGatewayNodeAddModalComponent, + NvmeofNamespaceExpandModalComponent, NvmeSubsystemViewComponent, NvmeofEditHostKeyModalComponent ], @@ -192,7 +195,8 @@ export class BlockModule { Reset, ProgressBarRound, SubtractAlt, - Search + Search, + Datastore ]); } } @@ -333,7 +337,18 @@ const routes: Routes = [ }, children: [ { path: '', redirectTo: 'gateways', pathMatch: 'full' }, - { path: 'gateways', component: NvmeofGatewayComponent, data: { breadcrumbs: 'Gateways' } }, + { + path: 'gateways', + component: NvmeofGatewayComponent, + data: { breadcrumbs: 'Gateways' }, + children: [ + { + path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`, + component: NvmeofNamespaceExpandModalComponent, + outlet: 'modal' + } + ] + }, { path: `gateways/${URLVerbs.CREATE}`, component: NvmeofGroupFormComponent, @@ -369,7 +384,7 @@ const routes: Routes = [ data: { breadcrumbs: 'Subsystems' }, children: [ // subsystems - { path: '', component: NvmeofSubsystemsComponent }, + { path: URLVerbs.CREATE, component: NvmeofSubsystemsFormComponent, @@ -389,8 +404,8 @@ const routes: Routes = [ }, { path: `${URLVerbs.EDIT}/:subsystem_nqn/namespace/:nsid`, - component: NvmeofNamespacesFormComponent, - data: { breadcrumbs: ActionLabels.EDIT + ' ' + $localize`Namespace` } + component: NvmeofNamespaceExpandModalComponent, + outlet: 'modal' }, // initiators { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html index 523c750caf1..a17657ae49e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-gateway/nvmeof-gateway.component.html @@ -44,4 +44,5 @@ + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.html new file mode 100644 index 00000000000..15aa4ce933c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.html @@ -0,0 +1,64 @@ + + +

Namespace

+

Expand namespace

+
+ +
+
+ Increase the NVMe namespace storage capacity by resizing the backing image. +
+ +
+
+ + namespace-{{ nsid }} +
+
+ Image: {{ imageName }} +
+
+ Current size: {{ currentBytes | dimlessBinary }} +
+
+ +
+
+ + + + +
+
+ + + @for (err of control.errors | keyvalue; track err.key) { + {{ INVALID_TEXTS[err.key] }} + } + +
+ + +
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.scss new file mode 100644 index 00000000000..e69de29bb2d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.spec.ts new file mode 100644 index 00000000000..f5e249c6c2f --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.spec.ts @@ -0,0 +1,94 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { NvmeofNamespaceExpandModalComponent } from './nvmeof-namespace-expand-modal.component'; +import { ActivatedRoute } from '@angular/router'; +import { ActivatedRouteStub } from '~/testing/activated-route-stub'; +import { NvmeofService } from '~/app/shared/api/nvmeof.service'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { SharedModule } from '~/app/shared/shared.module'; +import { ReactiveFormsModule } from '@angular/forms'; +import { RouterTestingModule } from '@angular/router/testing'; + +import { ToastrModule } from 'ngx-toastr'; +import { ModalModule, NumberModule } from 'carbon-components-angular'; +import { of } from 'rxjs'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { FormatterService } from '~/app/shared/services/formatter.service'; + +describe('NvmeofNamespaceExpandModalComponent', () => { + let component: NvmeofNamespaceExpandModalComponent; + let fixture: ComponentFixture; + let nvmeofService: NvmeofService; + + const mockNvmeofService = { + getNamespace: () => + of({ + nsid: '1', + rbd_pool_name: 'pool1', + rbd_image_name: 'image1', + rbd_image_size: new FormatterService().toBytes('1GiB'), + block_size: 4096, + rw_ios_per_second: 0, + rw_mbytes_per_second: 0, + r_mbytes_per_second: 0, + w_mbytes_per_second: 0 + }), + updateNamespace: () => of({}) + }; + + const activatedRouteStub = new ActivatedRouteStub( + { subsystem_nqn: 'nqn.2014-08.org.nvmexpress:uuid:12345', nsid: '1' }, + { group: 'group1' } + ); + // Mock the parent route for relative navigation + Object.defineProperty(activatedRouteStub, 'parent', { get: () => ({}) }); + + configureTestBed({ + declarations: [NvmeofNamespaceExpandModalComponent], + imports: [ + HttpClientTestingModule, + SharedModule, + ReactiveFormsModule, + RouterTestingModule, + ToastrModule.forRoot(), + ModalModule, + NumberModule + ], + providers: [ + { provide: NvmeofService, useValue: mockNvmeofService }, + { provide: ActivatedRoute, useValue: activatedRouteStub } + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(NvmeofNamespaceExpandModalComponent); + component = fixture.componentInstance; + nvmeofService = TestBed.inject(NvmeofService); + + // params are already set in constructor of stub above + + spyOn(nvmeofService, 'getNamespace').and.callThrough(); + spyOn(nvmeofService, 'updateNamespace').and.callThrough(); + + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize form with existing data', () => { + expect(component.nsForm.get('image_size').value).toBe(1); + }); + + it('should validate size - error if smaller', () => { + component.nsForm.get('image_size').setValue(0.5); + component.nsForm.get('image_size').updateValueAndValidity(); + expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true); + }); + + it('should validate size - success if larger', () => { + component.nsForm.get('image_size').setValue(2); + component.nsForm.get('image_size').updateValueAndValidity(); + expect(component.nsForm.get('image_size').hasError('minSize')).toBe(false); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.ts new file mode 100644 index 00000000000..540f3ba7483 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-namespace-expand-modal/nvmeof-namespace-expand-modal.component.ts @@ -0,0 +1,142 @@ +import { Component, OnInit, ViewChild } from '@angular/core'; +import { combineLatest } from 'rxjs'; + +import { UntypedFormControl, Validators } from '@angular/forms'; +import { ActivatedRoute, Router } from '@angular/router'; +import { NvmeofService, NamespaceUpdateRequest } from '~/app/shared/api/nvmeof.service'; +import { ActionLabelsI18n } 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 { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { CdValidators } from '~/app/shared/forms/cd-validators'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { FormButtonPanelComponent } from '~/app/shared/components/form-button-panel/form-button-panel.component'; +import { FormatterService } from '~/app/shared/services/formatter.service'; + +@Component({ + selector: 'cd-nvmeof-namespace-expand-modal', + templateUrl: './nvmeof-namespace-expand-modal.component.html', + styleUrls: ['./nvmeof-namespace-expand-modal.component.scss'], + standalone: false +}) +export class NvmeofNamespaceExpandModalComponent implements OnInit { + subsystemNQN: string; + nsid: string; + group: string; + + nsForm: CdFormGroup; + currentBytes: number; + currentSizeGiB: number; + imageName: string; + expandText: string = $localize`Expand`; + icons = Icons; + INVALID_TEXTS: Record = { + required: $localize`This field is required.`, + minSize: $localize`Value must be greater than the current image size.` + }; + + minSize: number = 0; + + @ViewChild(FormButtonPanelComponent) + formButtonPanel: FormButtonPanelComponent; + + constructor( + public actionLabels: ActionLabelsI18n, + private nvmeofService: NvmeofService, + private route: ActivatedRoute, + private router: Router, + public taskWrapper: TaskWrapperService, + private formatter: FormatterService + ) {} + + ngOnInit() { + this.createForm(); + combineLatest([this.route.params, this.route.queryParams]).subscribe( + ([params, queryParams]) => { + this.subsystemNQN = params['subsystem_nqn']; + this.nsid = params['nsid']; + this.group = queryParams['group']; + + if (this.subsystemNQN && this.nsid && this.group) { + this.initForEdit(); + } + } + ); + } + + createForm() { + this.nsForm = new CdFormGroup({ + image_size: new UntypedFormControl(null, { + validators: [ + Validators.required, + CdValidators.custom('minSize', (value: any) => { + if (this.currentBytes && value !== null && value !== undefined) { + const bytes = this.formatter.toBytes(`${value}GiB`); + if (bytes <= this.currentBytes) { + return { minSize: true }; + } + } + return null; + }) + ] + }) + }); + } + + initForEdit() { + this.nvmeofService + .getNamespace(this.subsystemNQN, this.nsid, this.group) + .subscribe((res: NvmeofSubsystemNamespace) => { + this.currentBytes = + typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size; + this.imageName = res.rbd_image_name; + this.currentSizeGiB = this.currentBytes / this.formatter.toBytes('1GiB'); + this.minSize = this.currentSizeGiB; + this.nsForm.patchValue({ + image_size: this.currentSizeGiB + }); + this.nsForm.get('image_size').updateValueAndValidity(); + }); + } + + closeModal() { + this.router.navigate([{ outlets: { modal: null } }], { + relativeTo: this.route.parent, + queryParamsHandling: 'preserve' + }); + } + + onSubmit() { + if (this.nsForm.invalid) { + this.nsForm.markAllAsTouched(); + this.nsForm.setErrors({ cdSubmitButton: true }); + if (this.formButtonPanel?.submitButton) { + this.formButtonPanel.submitButton.loading = false; + } + return; + } + + const image_size = this.nsForm.getValue('image_size'); + const rbdImageSize = this.formatter.toBytes(`${image_size}GiB`); + + const request: NamespaceUpdateRequest = { + gw_group: this.group, + rbd_image_size: rbdImageSize + }; + + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('nvmeof/namespace/edit', { + nqn: this.subsystemNQN, + nsid: this.nsid + }), + call: this.nvmeofService.updateNamespace(this.subsystemNQN, this.nsid, request) + }) + .subscribe({ + complete: () => { + this.closeModal(); + } + }); + } +} 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 index 4200fd73622..6c6f11fdf75 100644 --- 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 @@ -1,4 +1,6 @@

{{ action | titlecase }} {{ resource | titlecase }}

- + Namespaces define the storage volumes that subsystems present to hosts. @@ -19,54 +21,48 @@
- @if (!edit) {
- - -
- } - - @if (!edit) { +
+ i18n-helperText> + + @if (nsForm.controls['namespace_size'].hasError('blockSizeMultiple')) { + Size must be a multiple of the block size (typically 512 or 4096 bytes). + } +
- } - - @if (!edit) { +
@@ -103,10 +99,9 @@
- } - @if (!edit && nsForm.getValue('host_access') === 'specific') { + @if (nsForm.getValue('host_access') === 'specific') {
@@ -121,7 +116,7 @@ [items]="initiatorCandidates" (selected)="onInitiatorSelection($event)" [invalid]="nsForm.controls['initiators'].invalid && (nsForm.controls['initiators'].dirty || nsForm.controls['initiators'].touched)" - invalidText="This field is required." + invalidText="This field is required" i18n-invalidText i18n-placeholder> @@ -135,22 +130,11 @@
- @if (edit) { - - - - } @else { @if (subsystems === undefined) { @@ -165,6 +149,7 @@ } @if (subsystems && subsystems.length > 0) { } @@ -173,7 +158,6 @@ [value]="subsystem.nqn">{{ subsystem.nqn }} } - }
@@ -181,22 +165,11 @@
- @if (edit) { - - - - } @else { {{ pool.pool_name }} } - }
- - @if (!edit) { +
@@ -247,10 +218,9 @@
- } - - @if (!edit && nsForm.getValue('rbd_image_creation') === 'gateway_provisioned' && nsForm.getValue('nsCount') > 1) { + + @if (nsForm.getValue('rbd_image_creation') === 'gateway_provisioned' && nsForm.getValue('nsCount') > 1) {
@@ -261,43 +231,35 @@ For bulk namespace creation, RBD images are provisioned automatically. -
+
+ class="cds--type-label-01" + i18n>Image name (optional) + [invalid]="nsForm.controls['rbd_image_name'].invalid && (nsForm.controls['rbd_image_name'].dirty || nsForm.controls['rbd_image_name'].touched)" + [invalidText]="nsForm.getError('rbdImageName', 'rbd_image_name') ? 'Image name contains invalid characters' : ''" + i18n-helperText + i18n-invalidText> + formControlName="rbd_image_name" /> - - -
} - @if (!edit && nsForm.getValue('rbd_image_creation') === 'externally_managed') { + @if (nsForm.getValue('rbd_image_creation') === 'externally_managed') {
- @if (!edit && nsForm.getValue('rbd_image_creation') !== 'externally_managed') {
- Image Size - + Enter a valid size (e.g., 10GiB).
- }
- - @if (control.errors) { - @for (err of control.errors | keyvalue; track err.key) { - {{ INVALID_TEXTS[err.key] }} - } - } - 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 index 7ad2aec4e64..ddefda27c79 100644 --- 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 @@ -10,18 +10,17 @@ 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, Mocks } from '~/testing/unit-test-helper'; +import { FormatterService } from '~/app/shared/services/formatter.service'; import { NvmeofService } from '~/app/shared/api/nvmeof.service'; import { of, Observable } from 'rxjs'; import { PoolService } from '~/app/shared/api/pool.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { NumberModule, RadioModule, ComboBoxModule, SelectModule } from 'carbon-components-angular'; import { ActivatedRoute, Router } from '@angular/router'; -import { By } from '@angular/platform-browser'; + import { ActivatedRouteStub } from '~/testing/activated-route-stub'; -import { NvmeofInitiatorCandidate } from '~/app/shared/models/nvmeof'; const MOCK_POOLS = [ Mocks.getPool('pool-1', 1, ['cephfs']), @@ -47,7 +46,7 @@ const MOCK_NS_RESPONSE = { rbd_image_name: 'nvme_rbd_default_sscfagwuvvr', rbd_pool_name: 'rbd', load_balancing_group: 1, - rbd_image_size: '1073741824', + rbd_image_size: new FormatterService().toBytes('1GiB').toString(), block_size: 512, rw_ios_per_second: '0', rw_mbytes_per_second: '0', @@ -130,7 +129,7 @@ describe('NvmeofNamespacesFormComponent', () => { }); it('should create 5 namespaces correctly', () => { formHelper.setValue('pool', 'rbd'); - formHelper.setValue('image_size', 1073741824); + formHelper.setValue('image_size', new FormatterService().toBytes('1GiB')); formHelper.setValue('subsystem', MOCK_SUBSYSTEM); component.onSubmit(); expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(5); @@ -139,26 +138,10 @@ describe('NvmeofNamespacesFormComponent', () => { rbd_image_name: `nvme_rbd_default_${MOCK_RANDOM_STRING}`, rbd_pool: 'rbd', create_image: true, - rbd_image_size: 1073741824, + rbd_image_size: new FormatterService().toBytes('1GiB'), no_auto_visible: false }); }); - - it('should create multiple namespaces with suffixed custom image names', () => { - formHelper.setValue('pool', 'rbd'); - formHelper.setValue('image_size', 1073741824); - formHelper.setValue('subsystem', MOCK_SUBSYSTEM); - formHelper.setValue('nsCount', 2); - formHelper.setValue('rbd_image_name', 'test-img'); - component.onSubmit(); - expect(nvmeofService.createNamespace).toHaveBeenCalledTimes(2); - expect((nvmeofService.createNamespace as any).calls.argsFor(0)[1].rbd_image_name).toBe( - 'test-img-1' - ); - expect((nvmeofService.createNamespace as any).calls.argsFor(1)[1].rbd_image_name).toBe( - 'test-img-2' - ); - }); it('should give error on invalid image size', () => { formHelper.setValue('image_size', -56); component.onSubmit(); @@ -183,7 +166,7 @@ describe('NvmeofNamespacesFormComponent', () => { it('should call addNamespaceInitiators on submit with specific hosts', () => { formHelper.setValue('pool', 'rbd'); - formHelper.setValue('image_size', 1073741824); + formHelper.setValue('image_size', new FormatterService().toBytes('1GiB')); formHelper.setValue('subsystem', MOCK_SUBSYSTEM); formHelper.setValue('host_access', 'specific'); formHelper.setValue('initiators', ['host1']); @@ -199,66 +182,10 @@ describe('NvmeofNamespacesFormComponent', () => { }); it('should update initiators form control on selection', () => { - const mockEvent: NvmeofInitiatorCandidate[] = [ - { content: 'host1', selected: true }, - { content: 'host2', selected: true } - ]; + const mockEvent = [{ content: 'host1' }, { content: 'host2' }]; component.onInitiatorSelection(mockEvent); expect(component.nsForm.get('initiators').value).toEqual(['host1', 'host2']); expect(component.nsForm.get('initiators').dirty).toBe(true); }); }); - describe('should test edit form', () => { - beforeEach(() => { - router = TestBed.inject(Router); - nvmeofService = TestBed.inject(NvmeofService); - spyOn(nvmeofService, 'getNamespace').and.returnValue(of(MOCK_NS_RESPONSE)); - spyOn(nvmeofService, 'updateNamespace').and.returnValue( - of(new HttpResponse({ status: 200 })) - ); - Object.defineProperty(router, 'url', { - get: jasmine.createSpy('url').and.returnValue(MOCK_ROUTER.editUrl) - }); - fixture.detectChanges(); - }); - - it('should have set edit fields correctly', () => { - expect(nvmeofService.getNamespace).toHaveBeenCalledTimes(1); - expect(component.nsForm.get('pool').disabled).toBeTruthy(); - expect(component.nsForm.get('pool').value).toBe(MOCK_NS_RESPONSE['rbd_pool_name']); - // Size formatted by pipe - expect(component.nsForm.get('image_size').value).toBe('1 GiB'); - }); - - it('should not show namespace count', () => { - const nsCountEl = fixture.debugElement.query(By.css('cds-number[formControlName="nsCount"]')); - expect(nsCountEl).toBeFalsy(); - }); - - it('should give error with no change in image size', () => { - component.nsForm.get('image_size').updateValueAndValidity(); - expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true); - }); - - it('should give error when size less than previous (1 GB) provided', () => { - form = component.nsForm; - formHelper = new FormHelper(form); - formHelper.setValue('image_size', '512 MiB'); // Less than 1 GiB - component.nsForm.get('image_size').updateValueAndValidity(); - expect(component.nsForm.get('image_size').hasError('minSize')).toBe(true); - }); - - it('should have edited namespace successfully', () => { - component.ngOnInit(); - form = component.nsForm; - formHelper = new FormHelper(form); - formHelper.setValue('image_size', '2 GiB'); - component.onSubmit(); - expect(nvmeofService.updateNamespace).toHaveBeenCalledTimes(1); - expect(nvmeofService.updateNamespace).toHaveBeenCalledWith(MOCK_SUBSYSTEM, MOCK_NSID, { - gw_group: MOCK_GROUP, - rbd_image_size: 2147483648 - }); - }); - }); }); 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 index 1a6e47a9a83..2a185720eeb 100644 --- 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 @@ -1,10 +1,9 @@ -import { Component, OnDestroy, OnInit } from '@angular/core'; +import { Component, OnInit } from '@angular/core'; import { UntypedFormControl, Validators } from '@angular/forms'; import { ActivatedRoute, Router } from '@angular/router'; import { NamespaceCreateRequest, NamespaceInitiatorRequest, - NamespaceUpdateRequest, NvmeofService } from '~/app/shared/api/nvmeof.service'; import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; @@ -13,23 +12,17 @@ import { FinishedTask } from '~/app/shared/models/finished-task'; import { NvmeofSubsystem, NvmeofSubsystemInitiator, - NvmeofSubsystemNamespace, - NvmeofNamespaceListResponse, - NvmeofInitiatorCandidate, - NsFormField, - RbdImageCreation, - HOST_TYPE + 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 { RbdPool, RbdImage } from '~/app/shared/api/rbd.model'; import { RbdService } from '~/app/shared/api/rbd.service'; import { FormatterService } from '~/app/shared/services/formatter.service'; -import { forkJoin, Observable, of, Subject } from 'rxjs'; -import { filter, switchMap, takeUntil, tap } from 'rxjs/operators'; +import { forkJoin, Observable, of } from 'rxjs'; +import { switchMap } from 'rxjs/operators'; import { CdValidators } from '~/app/shared/forms/cd-validators'; import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe'; import { HttpResponse } from '@angular/common/http'; @@ -40,39 +33,27 @@ import { HttpResponse } from '@angular/common/http'; styleUrls: ['./nvmeof-namespaces-form.component.scss'], standalone: false }) -export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { +export class NvmeofNamespacesFormComponent implements OnInit { action: string; permission: Permission; poolPermission: Permission; resource: string; pageURL: string; - edit: boolean = false; + nsForm: CdFormGroup; subsystemNQN: string; - subsystems?: NvmeofSubsystem[]; - rbdPools: Pool[] | null = null; - rbdImages: RbdImage[] = []; - initiatorCandidates: NvmeofInitiatorCandidate[] = []; - - // Stores all RBD images fetched for the selected pool - private allRbdImages: RbdImage[] = []; - // Maps pool name to a Set of used image names for O(1) lookup - private usedRbdImages: Map> = new Map(); - private lastSubsystemNqn: string; + subsystems: NvmeofSubsystem[]; + rbdPools: Array = null; + rbdImages: any[] = []; + initiatorCandidates: { content: string; selected: boolean }[] = []; nsid: string; - currentBytes: number = 0; + group: string; MAX_NAMESPACE_CREATE: number = 5; MIN_NAMESPACE_CREATE: number = 1; - private destroy$ = new Subject(); - INVALID_TEXTS: Record = { - required: $localize`This field is required.`, - min: $localize`The namespace count should be between 1 and 5.`, - max: $localize`The namespace count should be between 1 and 5.`, - minSize: $localize`Enter a value larger than previous. A block device image can be expanded but not reduced.`, - rbdImageName: $localize`Image name contains invalid characters.` - }; + requiredInvalidText: string = $localize`This field is required`; + nsCountInvalidText: string = $localize`The namespace count should be between 1 and 5`; constructor( public actionLabels: ActionLabelsI18n, @@ -92,78 +73,45 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { this.pageURL = 'block/nvmeof/gateways'; } - ngOnDestroy() { - this.destroy$.next(); - this.destroy$.complete(); - } - init() { this.route.queryParams.subscribe((params) => { this.group = params?.['group']; - if (params?.['subsystem_nqn']) { - this.subsystemNQN = params?.['subsystem_nqn']; - } }); - 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, this.group) - .subscribe((res: NvmeofSubsystemNamespace) => { - this.currentBytes = - typeof res.rbd_image_size === 'string' ? Number(res.rbd_image_size) : res.rbd_image_size; - this.nsForm.get(NsFormField.POOL).setValue(res.rbd_pool_name); - this.nsForm - .get(NsFormField.IMAGE_SIZE) - .setValue(this.dimlessBinaryPipe.transform(res.rbd_image_size)); - this.nsForm.get(NsFormField.IMAGE_SIZE).addValidators(Validators.required); - this.nsForm.get(NsFormField.POOL).disable(); - this.nsForm.get(NsFormField.SUBSYSTEM).disable(); - this.nsForm.get(NsFormField.SUBSYSTEM).setValue(this.subsystemNQN); - }); + this.route.queryParams.subscribe((params) => { + if (params?.['subsystem_nqn']) { + this.subsystemNQN = params?.['subsystem_nqn']; + } + }); } initForCreate() { this.poolService.getList().subscribe((resp: Pool[]) => { this.rbdPools = resp.filter(this.rbdService.isRBDPool); }); - this.route.queryParams - .pipe( - filter((params) => params?.['group']), - tap((params) => { - this.group = params['group']; - this.fetchUsedImages(); - }), - switchMap(() => this.nvmeofService.listSubsystems(this.group)) - ) - .subscribe((subsystems: NvmeofSubsystem[]) => { + if (this.group) { + this.fetchUsedImages(); + this.nvmeofService.listSubsystems(this.group).subscribe((subsystems: NvmeofSubsystem[]) => { this.subsystems = subsystems; if (this.subsystemNQN) { const selectedSubsystem = this.subsystems.find((s) => s.nqn === this.subsystemNQN); if (selectedSubsystem) { - this.nsForm.get(NsFormField.SUBSYSTEM).setValue(selectedSubsystem.nqn); + this.nsForm.get('subsystem').setValue(selectedSubsystem.nqn); } } }); + } } ngOnInit() { this.init(); - if (this.router.url.includes('subsystems/(modal:edit')) { - this.initForEdit(); - } else { - this.initForCreate(); - } - const subsystemControl = this.nsForm.get(NsFormField.SUBSYSTEM); + this.initForCreate(); + const subsystemControl = this.nsForm.get('subsystem'); if (subsystemControl) { subsystemControl.valueChanges.subscribe((nqn: string) => { this.onSubsystemChange(nqn); @@ -171,19 +119,24 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { } } + // Stores all RBD images fetched for the selected pool + private allRbdImages: { name: string; size: number }[] = []; + // Maps pool name to a Set of used image names for O(1) lookup + private usedRbdImages: Map> = new Map(); + onPoolChange(): void { - const pool = this.nsForm.getValue(NsFormField.POOL); + const pool = this.nsForm.getValue('pool'); if (!pool) return; this.rbdService .list({ pool_name: pool, offset: '0', limit: '-1' }) - .subscribe((pools: RbdPool[]) => { + .subscribe((pools: { pool_name: string; value: { name: string; size: number }[] }[]) => { const selectedPool = pools.find((p) => p.pool_name === pool); this.allRbdImages = selectedPool?.value ?? []; this.filterImages(); - const imageControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME); - const currentImage = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME); + const imageControl = this.nsForm.get('rbd_image_name'); + const currentImage = this.nsForm.getValue('rbd_image_name'); if (currentImage && !this.rbdImages.some((img) => img.name === currentImage)) { imageControl.setValue(null); } @@ -195,26 +148,23 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { fetchUsedImages(): void { if (!this.group) return; - this.nvmeofService - .listNamespaces(this.group) - .subscribe((response: NvmeofNamespaceListResponse) => { - const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response) - ? response - : response?.namespaces ?? []; - this.usedRbdImages = namespaces.reduce((map, ns) => { - if (!map.has(ns.rbd_pool_name)) { - map.set(ns.rbd_pool_name, new Set()); - } - map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name); - return map; - }, new Map>()); - this.filterImages(); - }); + this.nvmeofService.listNamespaces(this.group).subscribe((response: any) => { + const namespaces: NvmeofSubsystemNamespace[] = Array.isArray(response) + ? response + : response?.namespaces ?? []; + this.usedRbdImages = namespaces.reduce((map, ns) => { + if (!map.has(ns.rbd_pool_name)) { + map.set(ns.rbd_pool_name, new Set()); + } + map.get(ns.rbd_pool_name)!.add(ns.rbd_image_name); + return map; + }, new Map>()); + this.filterImages(); + }); } onSubsystemChange(nqn: string): void { - if (!nqn || nqn === this.lastSubsystemNqn) return; - this.lastSubsystemNqn = nqn; + if (!nqn) return; this.nvmeofService .getInitiators(nqn, this.group) .subscribe((response: NvmeofSubsystemInitiator[] | { hosts: NvmeofSubsystemInitiator[] }) => { @@ -226,18 +176,18 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { }); } - onInitiatorSelection(event: NvmeofInitiatorCandidate[]) { + onInitiatorSelection(event: any) { // Carbon ComboBox (selected) emits the full array of selected items - const selectedInitiators = Array.isArray(event) ? event.map((e) => e.content) : []; + const selectedInitiators = Array.isArray(event) ? event.map((e: any) => e.content) : []; this.nsForm - .get(NsFormField.INITIATORS) + .get('initiators') .setValue(selectedInitiators.length > 0 ? selectedInitiators : null); - this.nsForm.get(NsFormField.INITIATORS).markAsDirty(); - this.nsForm.get(NsFormField.INITIATORS).markAsTouched(); + this.nsForm.get('initiators').markAsDirty(); + this.nsForm.get('initiators').markAsTouched(); } private filterImages(): void { - const pool = this.nsForm.getValue(NsFormField.POOL); + const pool = this.nsForm.getValue('pool'); if (!pool) { this.rbdImages = []; return; @@ -250,120 +200,82 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { createForm() { this.nsForm = new CdFormGroup({ - [NsFormField.POOL]: new UntypedFormControl('', { + pool: new UntypedFormControl('', { validators: [Validators.required] }), - [NsFormField.SUBSYSTEM]: new UntypedFormControl('', { + subsystem: new UntypedFormControl('', { validators: [Validators.required] }), - [NsFormField.IMAGE_SIZE]: new UntypedFormControl(null, { - validators: [ - Validators.required, - CdValidators.custom('minSize', (value: any) => { - if (value !== null && value !== undefined && value !== '') { - const bytes = this.formatterService.toBytes(value); - if ( - (!this.edit && bytes <= 0) || - (this.edit && this.currentBytes && bytes <= this.currentBytes) - ) { - return { minSize: true }; - } - } - return null; - }) - ], + image_size: new UntypedFormControl(null, { + validators: [Validators.required], updateOn: 'blur' }), - [NsFormField.NS_COUNT]: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [ + nsCount: new UntypedFormControl(this.MAX_NAMESPACE_CREATE, [ Validators.required, Validators.max(this.MAX_NAMESPACE_CREATE), Validators.min(this.MIN_NAMESPACE_CREATE) ]), - [NsFormField.RBD_IMAGE_CREATION]: new UntypedFormControl( - RbdImageCreation.GATEWAY_PROVISIONED - ), + rbd_image_creation: new UntypedFormControl('gateway_provisioned'), - [NsFormField.RBD_IMAGE_NAME]: new UntypedFormControl(null, [ + rbd_image_name: new UntypedFormControl(null, [ CdValidators.custom('rbdImageName', (value: any) => { if (!value) return null; return /^[^@/]+$/.test(value) ? null : { rbdImageName: true }; }) ]), - [NsFormField.NAMESPACE_SIZE]: new UntypedFormControl(null, [Validators.min(0)]), // sent as block_size in create request - [NsFormField.HOST_ACCESS]: new UntypedFormControl(HOST_TYPE.ALL), // drives no_auto_visible in create request - [NsFormField.INITIATORS]: new UntypedFormControl([]) // sent via addNamespaceInitiators API + namespace_size: new UntypedFormControl(null, { + validators: [CdValidators.blockSizeMultiple()] + }), // UI only - not sent to backend + host_access: new UntypedFormControl('all'), // UI only - determines visibility + initiators: new UntypedFormControl([]) // UI only - selected hosts }); - this.nsForm - .get(NsFormField.POOL) - .valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe(() => { - this.onPoolChange(); - }); + this.nsForm.get('pool').valueChanges.subscribe(() => { + this.onPoolChange(); + }); - this.nsForm - .get(NsFormField.NS_COUNT) - .valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe((count: number) => { - if (count > 1) { - const creationControl = this.nsForm.get(NsFormField.RBD_IMAGE_CREATION); - if (creationControl.value === RbdImageCreation.EXTERNALLY_MANAGED) { - creationControl.setValue(RbdImageCreation.GATEWAY_PROVISIONED); - } + this.nsForm.get('nsCount').valueChanges.subscribe((count: number) => { + if (count > 1) { + const creationControl = this.nsForm.get('rbd_image_creation'); + if (creationControl.value === 'externally_managed') { + creationControl.setValue('gateway_provisioned'); } - }); + } + }); - this.nsForm - .get(NsFormField.RBD_IMAGE_CREATION) - .valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe((mode: string) => { - const nameControl = this.nsForm.get(NsFormField.RBD_IMAGE_NAME); - const countControl = this.nsForm.get(NsFormField.NS_COUNT); - const imageSizeControl = this.nsForm.get(NsFormField.IMAGE_SIZE); - - if (mode === RbdImageCreation.EXTERNALLY_MANAGED) { - countControl.setValue(1); - countControl.disable(); - this.onPoolChange(); - nameControl.addValidators(Validators.required); - imageSizeControl.disable(); - imageSizeControl.removeValidators(Validators.required); - } else { - countControl.enable(); - nameControl.removeValidators(Validators.required); - imageSizeControl.enable(); - imageSizeControl.addValidators(Validators.required); - } - nameControl.updateValueAndValidity(); - imageSizeControl.updateValueAndValidity(); - }); + this.nsForm.get('rbd_image_creation').valueChanges.subscribe((mode: string) => { + const nameControl = this.nsForm.get('rbd_image_name'); + const sizeControl = this.nsForm.get('image_size'); + const countControl = this.nsForm.get('nsCount'); - this.nsForm - .get(NsFormField.HOST_ACCESS) - .valueChanges.pipe(takeUntil(this.destroy$)) - .subscribe((mode: string) => { - const initiatorsControl = this.nsForm.get(NsFormField.INITIATORS); - if (mode === HOST_TYPE.SPECIFIC) { - initiatorsControl.addValidators(Validators.required); - } else { - initiatorsControl.removeValidators(Validators.required); - initiatorsControl.setValue([]); - this.initiatorCandidates.forEach((i) => (i.selected = false)); - } - initiatorsControl.updateValueAndValidity(); - }); - } + if (mode === 'externally_managed') { + countControl.setValue(1); + countControl.disable(); + this.onPoolChange(); + nameControl.addValidators(Validators.required); + sizeControl.removeValidators(Validators.required); + sizeControl.disable(); + } else { + sizeControl.enable(); + countControl.enable(); + nameControl.removeValidators(Validators.required); + sizeControl.addValidators(Validators.required); + } + nameControl.updateValueAndValidity(); + sizeControl.updateValueAndValidity(); + }); - buildUpdateRequest(rbdImageSize: number): Observable> { - const request: NamespaceUpdateRequest = { - gw_group: this.group, - rbd_image_size: rbdImageSize - }; - return this.nvmeofService.updateNamespace( - this.subsystemNQN, - this.nsid, - request as NamespaceUpdateRequest - ); + this.nsForm.get('host_access').valueChanges.subscribe((mode: string) => { + const initiatorsControl = this.nsForm.get('initiators'); + if (mode === 'specific') { + initiatorsControl.addValidators(Validators.required); + } else { + initiatorsControl.removeValidators(Validators.required); + initiatorsControl.setValue([]); + this.initiatorCandidates.forEach((i) => (i.selected = false)); + } + initiatorsControl.updateValueAndValidity(); + }); } randomString() { @@ -375,10 +287,10 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { nsCount: number, noAutoVisible: boolean ): Observable>[] { - const pool = this.nsForm.getValue(NsFormField.POOL); + const pool = this.nsForm.getValue('pool'); const requests: Observable>[] = []; - const creationMode = this.nsForm.getValue(NsFormField.RBD_IMAGE_CREATION); - const isGatewayProvisioned = creationMode === RbdImageCreation.GATEWAY_PROVISIONED; + const creationMode = this.nsForm.getValue('rbd_image_creation'); + const isGatewayProvisioned = creationMode === 'gateway_provisioned'; const loopCount = isGatewayProvisioned ? nsCount : 1; @@ -390,7 +302,7 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { no_auto_visible: noAutoVisible }; - const blockSize = this.nsForm.getValue(NsFormField.NAMESPACE_SIZE); + const blockSize = this.nsForm.getValue('namespace_size'); if (blockSize) { request.block_size = blockSize; } @@ -402,12 +314,12 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { } } - const rbdImageName = this.nsForm.getValue(NsFormField.RBD_IMAGE_NAME); + const rbdImageName = this.nsForm.getValue('rbd_image_name'); if (rbdImageName) { - request['rbd_image_name'] = loopCount > 1 ? `${rbdImageName}-${i}` : rbdImageName; + request['rbd_image_name'] = rbdImageName; } - const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM) || this.subsystemNQN; + const subsystemNQN = this.nsForm.getValue('subsystem') || this.subsystemNQN; requests.push(this.nvmeofService.createNamespace(subsystemNQN, request)); } @@ -422,12 +334,12 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { } const component = this; - const taskUrl: string = `nvmeof/namespace/${this.edit ? URLVerbs.EDIT : URLVerbs.CREATE}`; - const image_size = this.nsForm.getValue(NsFormField.IMAGE_SIZE); - const nsCount = this.nsForm.getValue(NsFormField.NS_COUNT); - const hostAccess = this.nsForm.getValue(NsFormField.HOST_ACCESS); - const selectedHosts: string[] = this.nsForm.getValue(NsFormField.INITIATORS) || []; - const noAutoVisible = hostAccess === HOST_TYPE.SPECIFIC; + const taskUrl: string = `nvmeof/namespace/${URLVerbs.CREATE}`; + const image_size = this.nsForm.getValue('image_size'); + const nsCount = this.nsForm.getValue('nsCount'); + const hostAccess = this.nsForm.getValue('host_access'); + const selectedHosts: string[] = this.nsForm.getValue('initiators') || []; + const noAutoVisible = hostAccess === 'specific'; let action: Observable; let rbdImageSize: number = null; @@ -435,56 +347,46 @@ export class NvmeofNamespacesFormComponent implements OnInit, OnDestroy { rbdImageSize = this.formatterService.toBytes(image_size); } - if (this.edit) { - action = this.taskWrapperService.wrapTaskAroundCall({ - task: new FinishedTask(taskUrl, { - nqn: this.subsystemNQN, - nsid: this.nsid - }), - call: this.buildUpdateRequest(rbdImageSize) - }); - } else { - const subsystemNQN = this.nsForm.getValue(NsFormField.SUBSYSTEM); - - // Step 1: Create namespaces - // Step 2: If specific hosts selected, chain addNamespaceInitiators calls - const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible)); - - const combinedObs = createObs.pipe( - switchMap((responses: HttpResponse[]) => { - if (noAutoVisible && selectedHosts.length > 0) { - const initiatorObs: Observable[] = []; - - responses.forEach((res) => { - const body: any = res.body; - if (body && body.nsid) { - selectedHosts.forEach((host: string) => { - const req: NamespaceInitiatorRequest = { - gw_group: this.group, - subsystem_nqn: subsystemNQN || this.subsystemNQN, - host_nqn: host - }; - initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req)); - }); - } - }); - - if (initiatorObs.length > 0) { - return forkJoin(initiatorObs); + const subsystemNQN = this.nsForm.getValue('subsystem'); + + // Step 1: Create namespaces + // Step 2: If specific hosts selected, chain addNamespaceInitiators calls + const createObs = forkJoin(this.buildCreateRequest(rbdImageSize, nsCount, noAutoVisible)); + + const combinedObs = createObs.pipe( + switchMap((responses: HttpResponse[]) => { + if (noAutoVisible && selectedHosts.length > 0) { + const initiatorObs: Observable[] = []; + + responses.forEach((res) => { + const body: any = res.body; + if (body && body.nsid) { + selectedHosts.forEach((host: string) => { + const req: NamespaceInitiatorRequest = { + gw_group: this.group, + subsystem_nqn: subsystemNQN || this.subsystemNQN, + host_nqn: host + }; + initiatorObs.push(this.nvmeofService.addNamespaceInitiators(body.nsid, req)); + }); } + }); + + if (initiatorObs.length > 0) { + return forkJoin(initiatorObs); } - return of(responses); - }) - ); - - action = this.taskWrapperService.wrapTaskAroundCall({ - task: new FinishedTask(taskUrl, { - nqn: subsystemNQN, - nsCount - }), - call: combinedObs - }); - } + } + return of(responses); + }) + ); + + action = this.taskWrapperService.wrapTaskAroundCall({ + task: new FinishedTask(taskUrl, { + nqn: subsystemNQN, + nsCount + }), + call: combinedObs + }); action.subscribe({ error: () => { 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 index 7687318f953..9f3969b7e44 100644 --- 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 @@ -20,7 +20,6 @@ import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; import { BehaviorSubject, Observable, of, Subject } from 'rxjs'; import { catchError, map, switchMap, takeUntil } from 'rxjs/operators'; -const BASE_URL = 'block/nvmeof/subsystems'; const DEFAULT_PLACEHOLDER = $localize`Enter group name`; @Component({ @@ -108,19 +107,28 @@ export class NvmeofNamespacesListComponent implements OnInit, OnDestroy { disable: () => !this.group }, { - name: this.actionLabels.EDIT, + name: $localize`Expand`, permission: 'update', icon: Icons.edit, click: () => this.router.navigate( [ - BASE_URL, - URLVerbs.EDIT, - this.selection.first().ns_subsystem_nqn, - 'namespace', - this.selection.first().nsid + { + outlets: { + modal: [ + URLVerbs.EDIT, + this.selection.first().ns_subsystem_nqn, + 'namespace', + this.selection.first().nsid + ] + } + } ], - { queryParams: { group: this.group } } + { + relativeTo: this.route, + queryParams: { group: this.group }, + queryParamsHandling: 'merge' + } ) }, { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index bf0d514f72a..eec24923f43 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -89,6 +89,7 @@ export enum Icons { connect = 'connect', checkmarkOutline = 'checkmark--outline', circleDash = 'circle-dash', + datastore = 'datastore', /* Icons for special effect */ size16 = '16', size20 = '20', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts index 8166386405b..997564a983c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/forms/cd-validators.ts @@ -481,6 +481,22 @@ export class CdValidators { }; } + /** + * Validator function to ensure the entered value is a multiple of a typical block size (512 or 4096). + * It checks the numeric value directly against the modulo 512 calculation. + */ + static blockSizeMultiple(): ValidatorFn { + return (control: AbstractControl): { [key: string]: boolean } | null => { + const value = control.value; + if (value !== null && value !== undefined && value !== '') { + if (Number(value) % 512 !== 0) { + return { blockSizeMultiple: true }; + } + } + return null; + }; + } + /** * Asynchronous validator that checks if the password meets the password * policy. -- 2.47.3