From: Devika Babrekar Date: Thu, 2 Jul 2026 10:46:46 +0000 (+0530) Subject: mgr/dashboard: Converting Create OSDs tearsheet type from Full to Wide X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=4eb4e25e5e6f14ed70fee5c41b2669cc0c557e19;p=ceph.git mgr/dashboard: Converting Create OSDs tearsheet type from Full to Wide Fixes: https://tracker.ceph.com/issues/77132 Signed-off-by: Devika Babrekar --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 5a77bb1f1db..95f1449aa9b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -307,13 +307,13 @@ const routes: Routes = [ }, { path: 'osd', + component: OsdListComponent, data: { breadcrumbs: 'Cluster/OSDs' }, children: [ - { path: '', component: OsdListComponent }, { path: URLVerbs.CREATE, component: OsdFormComponent, - data: { breadcrumbs: ActionLabels.CREATE } + outlet: 'modal' } ] }, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html index 1523bdb986b..b95d4092086 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.html @@ -13,9 +13,11 @@ (emitDriveGroup)="setDriveGroup($event)" (emitDeploymentOption)="setDeploymentOptions($event)" (emitMode)="setDeploymentMode($event)" - (osdCreated)="onOsdCreated()"> + (osdCreated)="onOsdCreated()" + (cancelled)="onFormCancelled()"> } @else { } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.ts index 655e675f184..e15cffa08a6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/create-cluster/create-cluster-step-2/create-cluster-step-2.component.ts @@ -37,6 +37,10 @@ export class CreateClusterStep2Component implements OnInit, TearsheetStep { this.formGroup.patchValue({ skipped: true }); } + onFormCancelled() { + this.showForm = false; + } + onCreateAction() { this.showForm = true; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html index d536d41a9c6..91dc4ca6ecc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.html @@ -6,10 +6,11 @@ [steps]="steps" [title]="!hideTitle ? (action | titlecase) + ' ' + (resource | upperFirst) : ''" (submitRequested)="submit()" + (closeRequested)="onCancel()" (stepChanged)="populateReviewData()" [isSubmitLoading]="isSubmitLoading" [submitButtonLabel]="simpleDeployment ? createOsdsLabel : actionLabels.PREVIEW" - type="full"> + type="wide">
@@ -18,7 +19,7 @@ cdsStack="vertical" [gap]="6"> - @if (availDevices?.length === 0) { + @if (!hasEligibleDevices) { - +
+ [disabled]="!hasEligibleDevices || !deploymentOptions?.options[optionName].available ? true : null">
@@ -104,7 +106,8 @@
} - +
+
+ + Features + + + @for (feature of featureList; track feature.key) { + + {{ feature.desc }} + + } +
+
@@ -235,31 +255,6 @@ } - -
-
- -
- - Features - - - @for (feature of featureList; track feature.key) { - - {{ feature.desc }} - - } -
- -
-
-
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts index 5f46fc4c677..1a19376cf14 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.spec.ts @@ -215,24 +215,19 @@ describe('OsdFormComponent', () => { OsdDeploymentOptions.THROUGHPUT, OsdDeploymentOptions.IOPS ]); - expect(component.steps).toHaveLength(3); - expect(component.steps.map((step) => step.label)).toEqual([ - 'Deployment Options', - 'Features', - 'Review' - ]); + expect(component.steps).toHaveLength(2); + expect(component.steps.map((step) => step.label)).toEqual(['Deployment Options', 'Review']); }); it('should expand and collapse steps when deployment mode changes', () => { component.form.get('deploymentMode').setValue('manual'); fixture.detectChanges(); - expect(component.steps).toHaveLength(5); + expect(component.steps).toHaveLength(4); expect(component.steps.map((step) => step.label)).toEqual([ 'Deployment Options', 'Select data devices', 'Select DB/WAL devices (optional)', - 'Features', 'Review' ]); expect(fixture.nativeElement.textContent).toContain('Select data devices'); @@ -241,12 +236,8 @@ describe('OsdFormComponent', () => { component.form.get('deploymentMode').setValue('automatic'); fixture.detectChanges(); - expect(component.steps).toHaveLength(3); - expect(component.steps.map((step) => step.label)).toEqual([ - 'Deployment Options', - 'Features', - 'Review' - ]); + expect(component.steps).toHaveLength(2); + expect(component.steps.map((step) => step.label)).toEqual(['Deployment Options', 'Review']); expect(fixture.nativeElement.textContent).not.toContain('Select data devices'); expect(fixture.nativeElement.textContent).not.toContain('Select DB/WAL devices (optional)'); }); @@ -271,6 +262,13 @@ describe('OsdFormComponent', () => { expect(text).toContain('IOPS-optimized'); }); + it('should mark the first step invalid when no eligible devices are available', () => { + expect(component.hasEligibleDevices).toBe(false); + expect(component.steps[0].invalid).toBe(true); + expect(component.form.get('deploymentMode').disabled).toBe(true); + expect(component.form.get('deploymentOption').disabled).toBe(true); + }); + it('should only disable the options that are not available', () => { expect(deploymentOptions.options['throughput_optimized'].available).toBeFalsy(); expect(deploymentOptions.options['iops_optimized'].available).toBeFalsy(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts index ded0a1be5c9..6860134b3b9 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.ts @@ -8,6 +8,7 @@ import { ViewChild } from '@angular/core'; import { UntypedFormControl } from '@angular/forms'; +import { Location } from '@angular/common'; import { Router } from '@angular/router'; import _ from 'lodash'; @@ -94,6 +95,8 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { @Output() osdCreated: EventEmitter = new EventEmitter(); + @Output() cancelled: EventEmitter = new EventEmitter(); + icons = Icons; form!: CdFormGroup; @@ -140,6 +143,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { private orchService: OrchestratorService, private hostService: HostService, private router: Router, + private location: Location, private formatterService: FormatterService, private modalService: ModalService, private osdService: OsdService, @@ -158,22 +162,56 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { this.createForm(); } + get hasEligibleDevices(): boolean { + return this.allDevices.length > 0; + } + private getStepsForMode(mode: string): Array { + const firstStepInvalid = !this.hasEligibleDevices; + const subsequentStepDisabled = !this.hasEligibleDevices; return mode !== 'manual' ? [ - { label: STEP_LABELS.DEPLOYMENT, invalid: false }, - { label: STEP_LABELS.FEATURES, invalid: false }, - { label: STEP_LABELS.REVIEW, invalid: false } + { label: STEP_LABELS.DEPLOYMENT, invalid: firstStepInvalid }, + { label: STEP_LABELS.REVIEW, invalid: false, disabled: subsequentStepDisabled } ] : [ - { label: STEP_LABELS.DEPLOYMENT, invalid: false }, - { label: STEP_LABELS.DATA, invalid: false }, - { label: STEP_LABELS.DB_WAL, invalid: false }, - { label: STEP_LABELS.FEATURES, invalid: false }, - { label: STEP_LABELS.REVIEW, invalid: false } + { label: STEP_LABELS.DEPLOYMENT, invalid: firstStepInvalid }, + { label: STEP_LABELS.DATA, invalid: false, disabled: subsequentStepDisabled }, + { label: STEP_LABELS.DB_WAL, invalid: false, disabled: subsequentStepDisabled }, + { label: STEP_LABELS.REVIEW, invalid: false, disabled: subsequentStepDisabled } ]; } + private updateFirstStepInvalid() { + const invalid = !this.hasEligibleDevices; + const subsequentStepDisabled = !this.hasEligibleDevices; + const unchanged = + this.steps[0]?.invalid === invalid && + this.steps.slice(1).every((step) => step.disabled === subsequentStepDisabled); + if (unchanged) { + return; + } + this.steps = this.steps.map((step, i) => { + if (i === 0) { + return { ...step, invalid }; + } + return { ...step, disabled: subsequentStepDisabled }; + }); + } + + private updateDeploymentControlsAvailability() { + const deploymentMode = this.form.get('deploymentMode'); + const deploymentOption = this.form.get('deploymentOption'); + if (!this.hasEligibleDevices) { + deploymentMode?.disable({ emitEvent: false }); + deploymentOption?.disable({ emitEvent: false }); + } else { + deploymentMode?.enable({ emitEvent: false }); + deploymentOption?.enable({ emitEvent: false }); + } + this.updateFirstStepInvalid(); + } + private createEmptyReviewDeviceSelection(): ReviewDeviceSelection { return { count: 0, @@ -232,6 +270,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { private updateSteps() { const mode = this.form?.get('deploymentMode')?.value ?? 'automatic'; this.steps = this.getStepsForMode(mode); + this.updateFirstStepInvalid(); } ngOnInit() { @@ -311,11 +350,13 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { (devices: InventoryDevice[]) => { this.allDevices = _.filter(devices, 'available'); this.availDevices = [...this.allDevices]; + this.updateDeploymentControlsAvailability(); this.loadingReady(); }, () => { this.allDevices = []; this.availDevices = []; + this.updateDeploymentControlsAvailability(); this.loadingError(); } ); @@ -456,6 +497,14 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { ); } + onCancel() { + if (this.hideTitle) { + this.cancelled.emit(); + return; + } + this.location.back(); + } + private navigateAfterCreate() { const returnUrl = window.history.state?.returnUrl; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html index de9fd2a479d..ca7e1b800d2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.html @@ -165,3 +165,5 @@ + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts index bac3ee08d6e..2a7e150f992 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-list/osd-list.component.ts @@ -1,11 +1,11 @@ import { Component, - EventEmitter, Input, OnInit, - Output, TemplateRef, - ViewChild + ViewChild, + Output, + EventEmitter } from '@angular/core'; import { UntypedFormControl } from '@angular/forms'; import { Router } from '@angular/router'; @@ -39,7 +39,6 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { ModalService } from '~/app/shared/services/modal.service'; import { NotificationService } from '~/app/shared/services/notification.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; -import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { OsdFlagsIndivModalComponent } from '../osd-flags-indiv-modal/osd-flags-indiv-modal.component'; import { OsdFlagsModalComponent } from '../osd-flags-modal/osd-flags-modal.component'; import { OsdPgScrubModalComponent } from '../osd-pg-scrub-modal/osd-pg-scrub-modal.component'; @@ -57,11 +56,11 @@ const BASE_URL = 'osd'; selector: 'cd-osd-list', templateUrl: './osd-list.component.html', styleUrls: ['./osd-list.component.scss'], - providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(BASE_URL) }], standalone: false }) export class OsdListComponent extends ListWithDetails implements OnInit { @Input() showTabs = true; + @Input() inlineCreate = false; @Output() createAction = new EventEmitter(); @ViewChild('osdUsageTpl', { static: true }) @@ -121,7 +120,6 @@ export class OsdListComponent extends ListWithDetails implements OnInit { private osdService: OsdService, private dimlessBinaryPipe: DimlessBinaryPipe, private modalService: ModalService, - private urlBuilder: URLBuilderService, private router: Router, private taskWrapper: TaskWrapperService, public actionLabels: ActionLabelsI18n, @@ -137,14 +135,10 @@ export class OsdListComponent extends ListWithDetails implements OnInit { permission: 'create', icon: Icons.add, click: () => { - if (this.createAction.observers.length > 0) { + if (this.inlineCreate) { this.createAction.emit(); } else { - this.router.navigate([this.urlBuilder.getCreate()], { - state: { - returnUrl: this.router.url - } - }); + this.router.navigate([BASE_URL, { outlets: { modal: [URLVerbs.CREATE] } }]); } }, disable: (selection: CdTableSelection) => this.getDisable('create', selection), 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 a5b181968b2..c1bb0ac8c7a 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 @@ -81,6 +81,7 @@ @else { } @@ -194,6 +195,7 @@ @else { } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts index c886b41bc1c..791575ec8f7 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/tearsheet/tearsheet.component.spec.ts @@ -172,5 +172,18 @@ describe('TearsheetComponent', () => { tearsheetComponent.onNext(); expect(tearsheetComponent.currentStep).toBe(0); }); + + it('should disable next button when current step is invalid', () => { + hostComponent.steps = hostComponent.steps.map((step, i) => + i === 0 ? { ...step, invalid: true } : step + ); + hostFixture.detectChanges(); + const buttons = hostFixture.debugElement.queryAll( + By.css('.tearsheet-footer button[cdsButton="primary"]') + ); + const nextBtn = buttons.find((btn) => btn.nativeElement.textContent.trim() === 'Next'); + expect(nextBtn).toBeTruthy(); + expect(nextBtn?.nativeElement.disabled).toBe(true); + }); }); }); 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 dd91a045c95..2be126ef689 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 @@ -10,6 +10,8 @@ import { AfterViewInit, DestroyRef, OnDestroy, + OnChanges, + SimpleChanges, ChangeDetectionStrategy, TemplateRef, ViewEncapsulation @@ -61,7 +63,7 @@ formgroup: CdFormGroup; changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None }) -export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { +export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy, OnChanges { @Input() title!: string; @Input() steps!: Array; @Input() description!: string; @@ -134,13 +136,42 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { this.hasModalOutlet = this.route.outlet === 'modal'; } + ngOnChanges(changes: SimpleChanges) { + if (changes['steps']) { + this.lastStep = this.steps.length - 1; + if (this.currentStep > this.lastStep) { + this.currentStep = this.lastStep; + } + this.cdr.markForCheck(); + } + } + private _updateStepInvalid(index: number, invalid: boolean) { this.steps = this.steps.map((step, i) => (i === index ? { ...step, invalid } : step)); } onStepSelect(event: { step: Step; index: number }) { + if (this.isStepNavBlocked(event.index)) { + return; + } this.currentStep = event.index; this.stepChanged.emit({ current: this.currentStep }); + this.cdr.markForCheck(); + } + + private isStepNavBlocked(index: number): boolean { + if (this.steps[index]?.disabled) { + return true; + } + if (index > this.currentStep && this.steps[this.currentStep]?.invalid) { + return true; + } + for (let i = 0; i < index; i++) { + if (this.steps[i]?.invalid || this.steps[i]?.disabled) { + return true; + } + } + return false; } closeTearsheet() { @@ -154,6 +185,9 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { closeWideTearsheet() { this.closeRequested.emit(); this.isOpen = false; + if (this.closeRequested.observers.length > 0) { + return; + } if (this.hasModalOutlet) { this.location.back(); } else {