From: Afreen Misbah Date: Sun, 1 Feb 2026 23:47:23 +0000 (+0530) Subject: mgr/dashboard: Add step two of subsystem create form X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=bf71ac6747ed2fb6ebfe38edc7b40062c8c3e38d;p=ceph.git mgr/dashboard: Add step two of subsystem create form - add steps to add initiators - can add by input field - added right influencer (right panel) in tearsheet component - added unit tests - includes api updates Fixes https://tracker.ceph.com/issues/74096 Signed-off-by: Afreen Misbah --- diff --git a/src/pybind/mgr/dashboard/controllers/nvmeof.py b/src/pybind/mgr/dashboard/controllers/nvmeof.py index 8e28d19c8c9..f6f022d1d14 100644 --- a/src/pybind/mgr/dashboard/controllers/nvmeof.py +++ b/src/pybind/mgr/dashboard/controllers/nvmeof.py @@ -1434,13 +1434,37 @@ else: @empty_response @handle_nvmeof_error @CreatePermission - def add(self, subsystem_nqn: str, gw_group: str, host_nqn: str = ""): + def add(self, subsystem_nqn: str, host_nqn: str, dhchap_key: Optional[str] = None, + psk: Optional[str] = None, gw_group: Optional[str] = None, + server_address: Optional[str] = None + ): response = None - all_host_nqns = host_nqn.split(',') - for nqn in all_host_nqns: - response = NVMeoFClient(gw_group=gw_group).stub.add_host( - NVMeoFClient.pb2.add_host_req(subsystem_nqn=subsystem_nqn, host_nqn=nqn) + if host_nqn != '*': + all_host_nqns = host_nqn.split(',') + for nqn in all_host_nqns: + response = NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.add_host( + NVMeoFClient.pb2.add_host_req( + subsystem_nqn=subsystem_nqn, + host_nqn=nqn, + dhchap_key=dhchap_key, + psk=psk) + ) + if response.status != 0: + return response + else: + response = NVMeoFClient( + gw_group=gw_group, + server_address=server_address + ).stub.add_host( + NVMeoFClient.pb2.add_host_req( + subsystem_nqn=subsystem_nqn, + host_nqn=host_nqn, + dhchap_key=dhchap_key, + psk=psk) ) if response.status != 0: return response 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 77e8cafc230..e56338a7ee6 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 @@ -50,6 +50,7 @@ import { NvmeofInitiatorsFormComponent } from './nvmeof-initiators-form/nvmeof-i import { NvmeofGatewayGroupComponent } from './nvmeof-gateway-group/nvmeof-gateway-group.component'; import { NvmeofSubsystemsStepOneComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-1/nvmeof-subsystem-step-1.component'; import { NvmeofSubsystemsStepThreeComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-3/nvmeof-subsystem-step-3.component'; +import { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component'; import { NvmeofGatewayNodeComponent } from './nvmeof-gateway-node/nvmeof-gateway-node.component'; import { NvmeofGroupFormComponent } from './nvmeof-group-form /nvmeof-group-form.component'; @@ -70,6 +71,8 @@ import { TreeviewModule, TabsModule, TagModule, + LayoutModule, + ContainedListModule, LayerModule } from 'carbon-components-angular'; @@ -110,7 +113,9 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32'; TabsModule, TagModule, GridModule, - LayerModule + LayerModule, + LayoutModule, + ContainedListModule ], declarations: [ RbdListComponent, @@ -151,6 +156,7 @@ import ProgressBarRound from '@carbon/icons/es/progress-bar--round/32'; NvmeofGatewayNodeComponent, NvmeofGroupFormComponent, NvmeofSubsystemsStepOneComponent, + NvmeofSubsystemsStepTwoComponent, NvmeofSubsystemsStepThreeComponent ], exports: [RbdConfigurationListComponent, RbdConfigurationFormComponent] 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 new file mode 100644 index 00000000000..4bb3cdc92a2 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.html @@ -0,0 +1,146 @@ + +
+
+
+
+

Host access (Initiators)

+

Select hosts that can initiate connections to this subsystem.

+
+
+ + Allow all hosts + Any host can connect to this subsystem without verification. + @if(formGroup.get('hostType').value === HOST_TYPE.ALL) { + + Allowing all hosts grants access to every initiator on the network. Authentication is not supported in this mode, which may expose the subsystem to unauthorized access. + + } + + Restrict to specific hosts + + Recommended for secure environments + + + Add the specific hosts permitted to connect. + +
+ @if(formGroup.get('hostType').value === HOST_TYPE.SPECIFIC) { +
+

Add host manually

+ +
+ + + + +
+
+ } +
+
+
+ + + + + + + + + + + + + @if(addedHostsLength === 0) { +

Added hosts ({{addedHostsLength}})

+

No hosts added yet. Add hosts manually or upload a CSV file.

+ } @else { + + @for (host of formGroup.get('addedHosts')?.value ; track host) { + + {{host}} + + } + + } +
+ + +@for (err of formGroup.get('hostname').errors | keyvalue; track err.key) { +{{ INVALID_TEXTS[err.key] }} +} + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.scss new file mode 100644 index 00000000000..28149d5bd67 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.scss @@ -0,0 +1,31 @@ +.cd-nvmeof-subsystem-step-two { + &-manual-hosts { + display: flex; + align-items: flex-start; + } + + &-manual-hosts-input { + flex: 0.5; + margin-right: var(--cds-spacing-05); + } + + &-added-hosts-text { + color: var(--cds-text-secondary); + } + + &-specific-hosts-tag { + max-inline-size: 17rem; + margin: 0 var(--cds-spacing-01); + } + + &-influencer { + .cds--contained-list-item__content { + max-inline-size: 18rem !important; + padding-left: 0 !important; + } + + .cds--contained-list__header { + padding: 0 !important; + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.spec.ts new file mode 100644 index 00000000000..f1f95c71158 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.spec.ts @@ -0,0 +1,117 @@ +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 { NvmeofSubsystemsStepTwoComponent } from './nvmeof-subsystem-step-2.component'; +import { GridModule, InputModule, RadioModule, TagModule } from 'carbon-components-angular'; + +describe('NvmeofSubsystemsStepTwoComponent', () => { + let component: NvmeofSubsystemsStepTwoComponent; + let fixture: ComponentFixture; + let form: CdFormGroup; + const mockGroupName = 'default'; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [NvmeofSubsystemsStepTwoComponent], + providers: [NgbActiveModal], + imports: [ + HttpClientTestingModule, + NgbTypeaheadModule, + ReactiveFormsModule, + RouterTestingModule, + SharedModule, + InputModule, + GridModule, + RadioModule, + TagModule, + ToastrModule.forRoot() + ] + }).compileComponents(); + + fixture = TestBed.createComponent(NvmeofSubsystemsStepTwoComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + form = component.formGroup; + component.group = mockGroupName; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('form initialization', () => { + it('should initialize form with default values', () => { + expect(form).toBeTruthy(); + expect(form.get('hostType')?.value).toBe(component.HOST_TYPE.SPECIFIC); + expect(form.get('hostname')?.value).toBe(''); + expect(form.get('addedHosts')?.value).toEqual([]); + }); + }); + + describe('showRightInfluencer', () => { + it('should return true when hostType is SPECIFIC', () => { + form.get('hostType')?.setValue(component.HOST_TYPE.SPECIFIC); + expect(component.showRightInfluencer()).toBeTruthy(); + }); + + it('should return false when hostType is ALL', () => { + form.get('hostType')!.setValue(component.HOST_TYPE.ALL); + + expect(form.get('hostType')!.value).toBe(component.HOST_TYPE.ALL); + expect(component.showRightInfluencer()).toBeFalsy(); + }); + }); + + describe('hostname validation', () => { + it('should not require hostname when hostType is ALL', () => { + form.get('hostType')?.setValue(component.HOST_TYPE.ALL); + form.get('hostname')?.setValue(''); + + expect(form.get('hostname')?.hasError('required')).toBeFalsy(); + }); + }); + + describe('custom NQN validator', () => { + it('should mark invalid NQN format', () => { + form.get('hostname')?.setValue('invalid-nqn'); + + expect(form.get('hostname')?.hasError('pattern')).toBeTruthy(); + }); + + it('should accept valid NQN format', () => { + const validNqn = 'nqn.2023-01.com.example:host1'; + form.get('hostname')?.setValue(validNqn); + + expect(form.get('hostname')?.valid).toBeTruthy(); + }); + }); + + describe('addHost', () => { + it('should add hostname to addedHosts list', () => { + const hostname = 'nqn.2023-01.com.example:host1'; + form.get('hostname')?.setValue(hostname); + + component.addHost(); + + expect(form.get('addedHosts')?.value).toEqual([hostname]); + expect(component.addedHostsLength).toBe(1); + }); + + it('should not add empty hostname', () => { + form.get('hostname')?.setValue(''); + + component.addHost(); + + expect(form.get('addedHosts')?.value).toEqual([]); + expect(component.addedHostsLength).toBe(0); + }); + }); +}); 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 new file mode 100644 index 00000000000..6e350dcc42c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystem-step-2/nvmeof-subsystem-step-2.component.ts @@ -0,0 +1,106 @@ +import { Component, Input, OnInit, TemplateRef, ViewChild, ViewEncapsulation } from '@angular/core'; +import { FormControl, UntypedFormControl } from '@angular/forms'; +import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; + +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'; +import { HOST_TYPE } from '~/app/shared/models/nvmeof'; +import { TearsheetStep } from '~/app/shared/models/tearsheet-step'; + +@Component({ + selector: 'cd-nvmeof-subsystem-step-two', + templateUrl: './nvmeof-subsystem-step-2.component.html', + styleUrls: ['./nvmeof-subsystem-step-2.component.scss'], + standalone: false, + encapsulation: ViewEncapsulation.None +}) +export class NvmeofSubsystemsStepTwoComponent implements OnInit, TearsheetStep { + @Input() group!: string; + @ViewChild('rightInfluencer', { static: true }) + rightInfluencer?: TemplateRef; + formGroup: CdFormGroup; + action: string; + pageURL: string; + INVALID_TEXTS = { + pattern: $localize`Expected NQN format: "nqn.$year-$month.$reverseDomainName:$utf8-string" or "nqn.2014-08.org.nvmexpress:uuid:$UUID-string"`, + customRequired: $localize`This field is required`, + duplicate: $localize`Duplicate entry detected. Enter a unique value.` + }; + HOST_TYPE = HOST_TYPE; + addedHostsLength: number = 0; + 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 = '*'; + + constructor(public actionLabels: ActionLabelsI18n, public activeModal: NgbActiveModal) {} + + ngOnInit() { + this.createForm(); + } + + isValidNQN = CdValidators.custom( + 'pattern', + (input: string) => !!input && !(this.NQN_REGEX.test(input) || this.NQN_REGEX_UUID.test(input)) + ); + + isDuplicate = CdValidators.custom( + 'duplicate', + (input: string) => !!input && this.formGroup?.get('addedHosts')?.value.includes(input) + ); + + isRequired = CdValidators.custom( + 'customRequired', + (input: string) => + !input && + this.addedHostsLength === 0 && + this.formGroup?.get('hostType')?.value === this.HOST_TYPE.SPECIFIC + ); + + showRightInfluencer(): boolean { + return this.formGroup.get('hostType')?.value === this.HOST_TYPE.SPECIFIC; + } + + createForm() { + this.formGroup = new CdFormGroup({ + hostType: new UntypedFormControl(this.HOST_TYPE.SPECIFIC), + hostname: new FormControl('', { + validators: [this.isValidNQN, this.isDuplicate, this.isRequired] + }), + addedHosts: new FormControl([]) + }); + } + + addHost() { + const hostnameCtrl = this.formGroup.get('hostname'); + hostnameCtrl.markAsTouched(); + hostnameCtrl.updateValueAndValidity(); + if (hostnameCtrl.value && hostnameCtrl.valid) { + const addedHosts = this.formGroup.get('addedHosts').value; + const newHostList = [...addedHosts, hostnameCtrl.value]; + this.addedHostsLength = newHostList.length; + this.formGroup.patchValue({ + addedHosts: newHostList, + hostname: '' + }); + } + } + + removeHost(removedHost: string) { + const currentAddedHosts = this.formGroup.get('addedHosts').value; + const newHostList = currentAddedHosts.filter((currentHost) => currentHost !== removedHost); + this.addedHostsLength = newHostList.length; + this.formGroup.patchValue({ + addedHosts: newHostList + }); + this.formGroup.get('hostname').updateValueAndValidity(); + } + + removeAll() { + this.addedHostsLength = 0; + this.formGroup.patchValue({ + addedHosts: [] + }); + this.formGroup.get('hostname').updateValueAndValidity(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html index 59b322d6862..40c5fc997a7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.html @@ -11,8 +11,9 @@ [group]="group"> - + { let component: NvmeofSubsystemsFormComponent; @@ -26,7 +29,9 @@ describe('NvmeofSubsystemsFormComponent', () => { const mockPayload: SubsystemPayload = { nqn: '', gw_group: mockGroupName, - subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=' + subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=', + addedHosts: [], + hostType: HOST_TYPE.ALL }; beforeEach(async () => { @@ -35,7 +40,8 @@ describe('NvmeofSubsystemsFormComponent', () => { declarations: [ NvmeofSubsystemsFormComponent, NvmeofSubsystemsStepOneComponent, - NvmeofSubsystemsStepThreeComponent + NvmeofSubsystemsStepThreeComponent, + NvmeofSubsystemsStepTwoComponent ], providers: [NgbActiveModal], imports: [ @@ -66,7 +72,8 @@ describe('NvmeofSubsystemsFormComponent', () => { describe('should test form', () => { beforeEach(() => { nvmeofService = TestBed.inject(NvmeofService); - spyOn(nvmeofService, 'createSubsystem').and.stub(); + spyOn(nvmeofService, 'createSubsystem').and.returnValue(of({})); + spyOn(nvmeofService, 'addInitiators').and.returnValue(of({})); }); it('should be creating request correctly', () => { @@ -80,5 +87,23 @@ describe('NvmeofSubsystemsFormComponent', () => { dhchap_key: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=' }); }); + + it('should add initiators with wildcard when hostType is ALL', () => { + const payload: SubsystemPayload = { + nqn: 'test-nqn', + gw_group: mockGroupName, + addedHosts: [], + hostType: HOST_TYPE.ALL, + subsystemDchapKey: 'Q2VwaE52bWVvRkNoYXBTeW50aGV0aWNLZXkxMjM0NTY=' + }; + + component.group = mockGroupName; + component.onSubmit(payload); + + expect(nvmeofService.addInitiators).toHaveBeenCalledWith('test-nqn.default', { + host_nqn: '*', + gw_group: mockGroupName + }); + }); }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts index a85fb758611..fb25ba1f815 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/block/nvmeof-subsystems-form/nvmeof-subsystems-form.component.ts @@ -1,22 +1,32 @@ -import { Component, DestroyRef, OnInit, ViewChild } from '@angular/core'; +import { Component, DestroyRef, OnInit, SecurityContext, ViewChild } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; -import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; import { ActivatedRoute, Router } from '@angular/router'; import { Step } from 'carbon-components-angular'; -import { FinishedTask } from '~/app/shared/models/finished-task'; -import { NvmeofService } from '~/app/shared/api/nvmeof.service'; -import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { InitiatorRequest, NvmeofService } from '~/app/shared/api/nvmeof.service'; import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; +import { HOST_TYPE } from '~/app/shared/models/nvmeof'; +import { from, Observable, of } from 'rxjs'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { catchError, concatMap, map, tap } from 'rxjs/operators'; +import { DomSanitizer } from '@angular/platform-browser'; export type SubsystemPayload = { nqn: string; gw_group: string; subsystemDchapKey: string; + addedHosts: string[]; + hostType: string; }; +type StepResult = { step: string; success: boolean; error?: string }; + +const PAGE_URL = 'block/nvmeof/subsystems'; + @Component({ selector: 'cd-nvmeof-subsystems-form', templateUrl: './nvmeof-subsystems-form.component.html', @@ -39,12 +49,7 @@ export class NvmeofSubsystemsFormComponent implements OnInit { }, { label: $localize`Authentication`, - invalid: false - }, - { - label: $localize`Advanced options`, - complete: false, - secondaryLabel: $localize`Advanced` + complete: false } ]; title: string = $localize`Create Subsystem`; @@ -59,8 +64,9 @@ export class NvmeofSubsystemsFormComponent implements OnInit { private route: ActivatedRoute, private destroyRef: DestroyRef, private nvmeofService: NvmeofService, - private taskWrapperService: TaskWrapperService, - private router: Router + private notificationService: NotificationService, + private router: Router, + private sanitizer: DomSanitizer ) {} ngOnInit() { @@ -68,32 +74,90 @@ export class NvmeofSubsystemsFormComponent implements OnInit { this.group = params?.['group']; }); } - onSubmit(payload: SubsystemPayload) { - const component = this; - const pageURL = 'block/nvmeof/subsystems'; - let taskUrl = `nvmeof/subsystem/${URLVerbs.CREATE}`; this.isSubmitLoading = true; - this.taskWrapperService - .wrapTaskAroundCall({ - task: new FinishedTask(taskUrl, { - nqn: payload.nqn - }), - call: this.nvmeofService.createSubsystem({ - nqn: payload.nqn, - gw_group: this.group, - dhchap_key: payload.subsystemDchapKey, - enable_ha: true - }) + const stepResults: StepResult[] = []; + const initiatorRequest: InitiatorRequest = { + host_nqn: payload.hostType === HOST_TYPE.ALL ? '*' : payload.addedHosts.join(','), + gw_group: this.group + }; + + this.nvmeofService + .createSubsystem({ + nqn: payload.nqn, + gw_group: this.group, + enable_ha: true, + dhchap_key: payload.subsystemDchapKey }) .subscribe({ - error() { - component.isSubmitLoading = false; + next: () => { + stepResults.push({ step: this.steps[0].label, success: true }); + this.runSequentialSteps( + [ + { + step: this.steps[1].label, + call: () => + this.nvmeofService.addInitiators(`${payload.nqn}.${this.group}`, initiatorRequest) + } + ], + stepResults + ).subscribe({ + complete: () => this.showFinalNotification(stepResults) + }); }, - complete: () => { - component.isSubmitLoading = false; - this.router.navigate([pageURL, { outlets: { modal: null } }]); + error: (err) => { + err.preventDefault(); + const errorMsg = err?.error?.detail || $localize`Subsystem creation failed`; + this.notificationService.show( + NotificationType.error, + $localize`Subsystem creation failed`, + errorMsg + ); + this.isSubmitLoading = false; + this.router.navigate([PAGE_URL, { outlets: { modal: null } }]); } }); } + + private runSequentialSteps( + steps: { step: string; call: () => Observable }[], + stepResults: StepResult[] + ): Observable { + return from(steps).pipe( + concatMap((step) => + step.call().pipe( + tap(() => stepResults.push({ step: step.step, success: true })), + catchError((err) => { + err.preventDefault(); + const errorMsg = err?.error?.detail || ''; + stepResults.push({ step: step.step, success: false, error: errorMsg }); + return of(null); + }) + ) + ), + map(() => void 0) + ); + } + + private showFinalNotification(stepResults: StepResult[]) { + this.isSubmitLoading = false; + + const messageLines = stepResults.map((stepResult) => + stepResult.success + ? $localize`
${stepResult.step} step created successfully

` + : $localize`
${stepResult.step} step failed: ${stepResult.error}

` + ); + + const rawHtml = messageLines.join('
'); + const sanitizedHtml = this.sanitizer.sanitize(SecurityContext.HTML, rawHtml) ?? ''; + + const hasFailure = stepResults.some((r) => !r.success); + const type = hasFailure ? NotificationType.error : NotificationType.success; + const title = hasFailure + ? $localize`Subsystem created (with errors)` + : $localize`Subsystem created`; + + this.notificationService.show(type, title, sanitizedHtml); + this.router.navigate([PAGE_URL, { outlets: { modal: null } }]); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts index 238f8ef5caa..0607b8dfe27 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts @@ -106,7 +106,7 @@ import ErrorFilledIcon from '@carbon/icons/es/error--filled/16'; import InformationFilledIcon from '@carbon/icons/es/information--filled/16'; import WarningFilledIcon from '@carbon/icons/es/warning--filled/16'; import NotificationFilledIcon from '@carbon/icons/es/notification--filled/16'; -import CloseIcon from '@carbon/icons/es/close/16'; +import { Close16 } from '@carbon/icons'; import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component'; import { ProductiveCardComponent } from './productive-card/productive-card.component'; @@ -268,7 +268,7 @@ export class ComponentsModule { InformationFilledIcon, WarningFilledIcon, NotificationFilledIcon, - CloseIcon + Close16 ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts index 946040a3056..2ddcc4abcd1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet-step/tearsheet-step.component.ts @@ -13,4 +13,14 @@ export class TearsheetStepComponent { @ContentChild('tearsheetStep') stepComponent!: TearsheetStep; + + get rightInfluencer(): TemplateRef | null { + return this.stepComponent?.rightInfluencer ?? null; + } + + get showRightInfluencer(): boolean { + return this.stepComponent?.showRightInfluencer + ? this.stepComponent.showRightInfluencer() + : false; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html index 19bff6c52f2..4b49eda0137 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.html @@ -25,7 +25,7 @@
+ class="tearsheet-left-influencer">
- +
+ [columnNumbers]="{lg: 3, md: 3, sm: 3}" + class="tearsheet-left-influencer">
- + @if (showRightInfluencer) { + +
+
+ +
+ +
+ } + @else { +
- - +
+ } @@ -140,7 +161,7 @@ [overlay]="false" size="sm"> - {{submitButtonLoadingLabel}}... + {{submitButtonLoadingLabel}}... } @else { {{submitButtonLabel}} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss index 405e440cefd..bbcd84b1701 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.scss @@ -36,7 +36,7 @@ // HEADER .tearsheet-header { fill: var(--cds-icon-primary); - background-color: var(--cds-layer-01); + background-color: var(--cds-background); padding: var(--cds-spacing-06) var(--cds-spacing-07); border-block-end: 1px solid var(--cds-border-subtle-01); @@ -62,44 +62,48 @@ padding: 0; margin: 0; height: 100%; +} + +.tearsheet-left-influencer { + background-color: var(--cds-background); + padding: var(--cds-spacing-06) var(--cds-spacing-07); + overflow-block: auto; + overflow-y: auto; + border-inline-end: 1px solid var(--cds-border-subtle-01); + margin: 0; +} - .tearsheet-influencer { - background-color: var(--cds-layer-01); - padding: var(--cds-spacing-06) var(--cds-spacing-07); - overflow-block: auto; - overflow-y: auto; - border-inline-end: 1px solid var(--cds-border-subtle-01); - margin: 0; +.tearsheet-right-influencer { + background-color: var(--cds-background); + padding: var(--cds-spacing-05) var(--cds-spacing-05); +} + +.tearsheet-main { + margin: 0; + height: 100%; + display: flex; + flex-direction: column; + + > div { + flex: 1; } +} - .tearsheet-main { - margin: 0; - display: flex; - flex-direction: column; - height: 100%; - - .tearsheet-content { - background-color: var(--cds-background); - margin: 0; - padding: var(--cds-spacing-06) var(--cds-spacing-07); - flex: 1; - overflow-y: auto; - - &--full { - padding-left: 0; - } - } +.tearsheet-content { + background-color: var(--cds-layer-01); + margin: 0; + padding: var(--cds-spacing-06) var(--cds-spacing-07); + overflow-y: auto; + + &--full { + padding-left: 0; } } //FOOTER .tearsheet-footer { border-top: 1px solid var(--cds-border-subtle); - background: var(--cds-background); - - &-cancel { - margin-left: var(--cds-spacing-05); - } + background-color: var(--cds-layer-01); &-submit { display: flex; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts index 36d3002eb29..71419dd2664 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.ts @@ -8,7 +8,9 @@ import { QueryList, AfterViewInit, DestroyRef, - OnDestroy + OnDestroy, + ChangeDetectionStrategy, + TemplateRef } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { Step } from 'carbon-components-angular'; @@ -51,7 +53,8 @@ formgroup: CdFormGroup; selector: 'cd-tearsheet', standalone: false, templateUrl: './tearsheet.component.html', - styleUrls: ['./tearsheet.component.scss'] + styleUrls: ['./tearsheet.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush }) export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { @Input() title!: string; @@ -72,6 +75,14 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { return this.stepContents?.toArray()[this.currentStep]?.template; } + get rightInfluencerTemplate(): TemplateRef | null { + return this.stepContents?.toArray()[this.currentStep]?.rightInfluencer ?? null; + } + + get showRightInfluencer(): boolean { + return this.stepContents?.toArray()[this.currentStep]?.showRightInfluencer; + } + currentStep: number = 0; lastStep: number = null; isOpen: boolean = true; @@ -124,6 +135,8 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { } onNext() { + const formEl = document.querySelector('form'); + formEl?.dispatchEvent(new Event('submit', { bubbles: true })); if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) { this.currentStep = this.currentStep + 1; } 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 27b1f333754..67f8da625c2 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 @@ -126,5 +126,6 @@ export const ICON_TYPE = { notificationOff: 'notification--off', notificationNew: 'notification--new', success: 'success', - warning: 'warning' + warning: 'warning', + add: 'add' } as const; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts index 7b3eb8d87de..976435853ed 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/nvmeof.ts @@ -64,3 +64,8 @@ export enum AUTHENTICATION { Unidirectional = 'unidirectional', Bidirectional = 'bidirectional' } + +export const HOST_TYPE = { + ALL: 'all', + SPECIFIC: 'specific' +}; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts index 556bb834d98..9688fc08ba5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/tearsheet-step.ts @@ -1,5 +1,8 @@ +import { TemplateRef } from '@angular/core'; import { FormGroup } from '@angular/forms'; export interface TearsheetStep { formGroup: FormGroup; + rightInfluencer?: TemplateRef; + showRightInfluencer?: () => boolean; } diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss index b4eb9862906..fa9cf5cb746 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -1,5 +1,6 @@ @use '@carbon/layout'; +// PADDINGS .cds-p-0 { padding: 0; } @@ -8,20 +9,13 @@ padding-top: layout.$spacing-03; } -.cds-ml-3 { - margin-left: layout.$spacing-03; -} - -.cds-ml-5 { - margin-left: layout.$spacing-05; -} - -.cds-mr-3 { - margin-right: layout.$spacing-03; +// MARGINS +.cds-m-0 { + margin: 0; } -.cds-mr-5 { - margin-right: layout.$spacing-05; +.cds-mb-0 { + margin-bottom: 0; } .cds-mb-1 { @@ -55,3 +49,19 @@ .cds-mt-6 { margin-top: layout.$spacing-06; } + +.cds-ml-3 { + margin-left: layout.$spacing-03; +} + +.cds-ml-5 { + margin-left: layout.$spacing-05; +} + +.cds-mr-3 { + margin-right: layout.$spacing-03; +} + +.cds-mr-5 { + margin-right: layout.$spacing-05; +}