From: Syed Ali Ul Hasan Date: Wed, 10 Jun 2026 17:30:36 +0000 (+0530) Subject: mgr/dashboard: carbonized OSD form component X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=78dad340ab180532082c39a52efc0dfce244ec6e;p=ceph.git mgr/dashboard: carbonized OSD form component Fixes: https://tracker.ceph.com/issues/68265 Signed-off-by: Syed Ali Ul Hasan --- diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts index a711704ad6e..3edd74a4397 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts @@ -27,7 +27,8 @@ import { TabsModule, RadioModule, TilesModule, - LayerModule + LayerModule, + AccordionModule } from 'carbon-components-angular'; import Analytics from '@carbon/icons/es/analytics/16'; import CloseFilled from '@carbon/icons/es/close--filled/16'; @@ -145,7 +146,8 @@ import { TextLabelListComponent } from '~/app/shared/components/text-label-list/ FileUploaderModule, RadioModule, TilesModule, - LayerModule + LayerModule, + AccordionModule ], declarations: [ MonitorComponent, 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 c847a491e7d..1523bdb986b 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 @@ -8,11 +8,16 @@

Create OSDs

- + @if (showForm) { + + } @else { + + }
- - -
- - {{ filter.name }}: {{ filter.value.formatted }} - - - - Clear - -
-
- - -
-
- Raw capacity: {{ capacity | dimlessBinary }} -
-
+ @if (devices.length === 0 && inlineSelection) { + @if (!canSelect) { + + {{ tooltips.addPrimaryFirst }} + + } + @if (canSelect) { + @if (availDevices.length === 0) { + + No available devices + + } + @if (availDevices.length > 0 && !canInlineSubmit) { + + At least one of these filters must be applied in order to proceed: + @for (filter of requiredFilters; track filter) { + {{ filter }} + } + + } + + + @if (canInlineSubmit) { +
+ Number of devices: {{ inlineFilteredDevices.length }}. Raw capacity: + {{ inlineCapacity | dimlessBinary }}. +
+ } + + } + } @else { + @if (devices.length === 0) { + + } @else { +
+ + {{ filter.name }}: {{ filter.value.formatted }} + + + + Clear + +
+
+ + +
+ @if (type === 'data') { +
+ Raw capacity: {{ capacity | dimlessBinary }} +
+ } + } + } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss index 3fb8f6b3848..bf3223c234d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.scss @@ -1,3 +1,17 @@ .tc_clearSelections { text-decoration: none; } + +.osd-devices-selection-row { + .cd-form-label, + .cd-col-form-input { + flex: 0 0 100%; + max-width: 100%; + width: 100%; + } + + cd-inventory-devices { + display: block; + width: 100%; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts index 5acb7c51b99..79d03d10b45 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-devices-selection-groups/osd-devices-selection-groups.component.ts @@ -31,6 +31,8 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { @Input() canSelect: boolean; + @Input() inlineSelection = false; + @Output() selected = new EventEmitter(); @@ -45,6 +47,23 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { isOsdPage: boolean; addButtonTooltip: String; + filterColumns = [ + 'hostname', + 'human_readable_type', + 'sys_api.vendor', + 'sys_api.model', + 'sys_api.size' + ]; + requiredFilters: string[] = [ + $localize`Type`, + $localize`Vendor`, + $localize`Model`, + $localize`Size` + ]; + inlineFilteredDevices: InventoryDevice[] = []; + inlineCapacity = 0; + canInlineSubmit = false; + inlineFilterEvent?: CdTableColumnFiltersChange; tooltips = { noAvailDevices: $localize`No available devices`, addPrimaryFirst: $localize`Please add primary devices first`, @@ -77,39 +96,65 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { } showSelectionModal() { - const filterColumns = [ - 'hostname', - 'human_readable_type', - 'sys_api.vendor', - 'sys_api.model', - 'sys_api.size' - ]; const diskType = this.name === 'Primary' ? 'hdd' : 'ssd'; const initialState = { hostname: this.hostname, deviceType: this.name, diskType: diskType, devices: this.availDevices, - filterColumns: filterColumns + filterColumns: this.filterColumns }; const modalRef = this.modalService.show(OsdDevicesSelectionModalComponent, initialState, { size: 'xl' }); modalRef.componentInstance.submitAction.subscribe((result: CdTableColumnFiltersChange) => { - this.devices = result.data; - this.capacity = _.sumBy(this.devices, 'sys_api.size'); - this.appliedFilters = result.filters; - const event = _.assign({ type: this.type }, result); - if (!this.isOsdPage) { - this.osdService.osdDevices[this.type] = this.devices; - this.osdService.osdDevices['disableSelect'] = - this.canSelect || this.devices.length === this.availDevices.length; - this.osdService.osdDevices[this.type]['capacity'] = this.capacity; - } - this.selected.emit(event); + this.applySelectionResult(result); }); } + onInlineFilterChange(event: CdTableColumnFiltersChange) { + this.inlineCapacity = 0; + this.canInlineSubmit = false; + this.inlineFilterEvent = undefined; + + if (_.isEmpty(event.filters)) { + this.inlineFilteredDevices = []; + return; + } + + const filters = event.filters.filter((filter) => filter.prop !== 'hostname'); + this.canInlineSubmit = !_.isEmpty(filters); + this.inlineFilteredDevices = event.data; + this.inlineCapacity = _.sumBy(this.inlineFilteredDevices, 'sys_api.size'); + this.inlineFilterEvent = event; + } + + submitInlineSelection() { + if ( + !this.inlineFilterEvent || + !this.canInlineSubmit || + this.inlineFilteredDevices.length === 0 + ) { + return; + } + + this.applySelectionResult(this.inlineFilterEvent); + } + + private applySelectionResult(result: CdTableColumnFiltersChange) { + this.devices = result.data; + this.capacity = _.sumBy(this.devices, 'sys_api.size'); + this.appliedFilters = result.filters; + const event = _.assign({ type: this.type }, result); + if (!this.isOsdPage) { + this.osdService.osdDevices[this.type] = this.devices; + this.osdService.osdDevices['disableSelect'] = + this.canSelect || this.devices.length === this.availDevices.length; + this.osdService.osdDevices[this.type]['capacity'] = this.capacity; + } + this.selected.emit(event); + } + private updateAddButtonTooltip() { if (this.type === 'data' && this.availDevices.length === 0) { this.addButtonTooltip = this.tooltips.noAvailDevices; @@ -136,6 +181,10 @@ export class OsdDevicesSelectionGroupsComponent implements OnInit, OnChanges { clearedDevices: [...this.devices] }; this.devices = []; + this.inlineFilteredDevices = []; + this.inlineCapacity = 0; + this.canInlineSubmit = false; + this.inlineFilterEvent = undefined; this.cleared.emit(event); } } 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 d232aba2339..d536d41a9c6 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 @@ -1,221 +1,449 @@ - - -
-
{{ action | titlecase }} {{ resource | upperFirst }}
-
-
- -
- No eligible devices found for OSD creation. - Physical disks may be present, but none meet the requirements (unused, unformatted, and not already configured by Ceph). -
-
-
-
-

- -

-
-
-
-
-
- - +@if (!hasOrchestrator) { + +} + + + + +
+ + + @if (availDevices?.length === 0) { + +
+ + No eligible devices found for OSD creation. + + + Physical disks may be present, but none meet the requirements + (unused, unformatted, and not already configured by Ceph). + +
+
+ } + +
+ + Deployment Options + + +
+ + + +
+ + Automatic + +
+ Choose a pre-configured profile for you.
- - -
-
-
-

- -

-
-
-
-
-
- - -
- - -
- Shared devices - - - - - - -
- -
- - Value should be greater than or equal to 0 -
-
- - - - - - -
- -
- - Value should be greater than or equal to 0 -
-
-
-
-
+
+ + } +
+ } - -
-

- -

-
-
-
-
-
- - + +
+ + Manual selection + +
+ Custom Configuration
+
+ +
+
+ + +
+ + + @if (form.get('deploymentMode').value === 'manual') { + +
+
+ +
+ + Select data devices + + + +
+ +
+
+
+ + +
+
+ +
+ + Select DB/WAL devices (optional) + + +
+ + + + @if (walDeviceSelectionGroups.devices.length !== 0) { +
+ +
+ } + + + + + @if (dbDeviceSelectionGroups.devices.length !== 0) { +
+ + +
+ }
+
+
+ } - + + + +
+
+
+

Review summary

+
+ +
+

Deployment mode

+

{{ reviewDeploymentModeLabel }}

+
+ + @if (simpleDeployment) { +
+

Profile

+

{{ reviewDeploymentOptionTitle }}

+
+ +
+

Profile details

+

{{ reviewDeploymentOptionDescription }}

+
+ } @else { +
+

Host pattern

+

{{ reviewHostPattern }}

+
+ +
+

Device selections

+
+ +
+

Data devices

+ @if (reviewDataSelection.hasSelection) { +

{{ reviewDataSelection.count }} device(s) selected

+

Total capacity: {{ reviewDataSelection.capacity }}

+ @if (reviewDataSelection.filters.length > 0) { +
+ @for (filter of reviewDataSelection.filters; track filter.label + filter.value) { +

{{ filter.label }}

+

{{ filter.value }}

+ } +
+ } + } @else { +

None selected

+ } +
+ +
+

WAL devices

+ @if (reviewWalSelection.hasSelection) { +

{{ reviewWalSelection.count }} device(s) selected

+

Total capacity: {{ reviewWalSelection.capacity }}

+

WAL slots: {{ reviewWalSelection.slots }}

+ @if (reviewWalSelection.filters.length > 0) { +
+ @for (filter of reviewWalSelection.filters; track filter.label + filter.value) { +

{{ filter.label }}

+

{{ filter.value }}

+ } +
+ } + } @else { +

None selected

+ } +
+ +
+

DB devices

+ @if (reviewDbSelection.hasSelection) { +

{{ reviewDbSelection.count }} device(s) selected

+

Total capacity: {{ reviewDbSelection.capacity }}

+

DB slots: {{ reviewDbSelection.slots }}

+ @if (reviewDbSelection.filters.length > 0) { +
+ @for (filter of reviewDbSelection.filters; track filter.label + filter.value) { +

{{ filter.label }}

+

{{ filter.value }}

+ } +
+ } + } @else { +

None selected

+ } +
+ } + +
+

Features

+
+ +
+ @if (reviewEnabledFeatures.length > 0) { + @for (feature of reviewEnabledFeatures; track feature) { +

{{ feature }}

+ } + } @else { +

No features enabled

+ } +
+
+
+
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss index e69de29bb2d..6322e7e939b 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-form/osd-form.component.scss @@ -0,0 +1,24 @@ +.osd-tearsheet-content { + padding: var(--cds-spacing-05) var(--cds-spacing-06); + max-height: calc(100vh - 300px); + overflow-y: auto; + overflow-x: hidden; +} + +.osd-alert-block { + display: block; +} + +.osd-radio-label-wrapper { + display: flex; + flex-direction: column; +} + +.osd-radio-helper-text { + max-width: 25rem; + white-space: normal; +} + +.osd-review-section { + border-left: 1px solid var(--cds-border-subtle-01); +} 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 9cd09bfa2a7..5f46fc4c677 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 @@ -3,6 +3,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { RouterTestingModule } from '@angular/router/testing'; +import { CheckboxModule, NumberModule, RadioModule } from 'carbon-components-angular'; import { BehaviorSubject, of } from 'rxjs'; @@ -21,6 +22,7 @@ import { configureTestBed, FixtureHelper, FormHelper } from '~/testing/unit-test import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface'; import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface'; import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component'; +import { OsdDeviceType } from '~/app/shared/models/osd-form'; import { OsdFormComponent } from './osd-form.component'; describe('OsdFormComponent', () => { @@ -93,11 +95,17 @@ describe('OsdFormComponent', () => { }; const expectPreviewButton = (enabled: boolean) => { - const debugElement = fixtureHelper.getElementByCss('.tc_submitButton'); - expect(debugElement.nativeElement.disabled).toBe(!enabled); + expect(component.dataDeviceSelectionGroups.devices.length > 0).toBe(enabled); }; - const selectDevices = (type: string) => { + const ensureSelectionGroups = () => { + component.dataDeviceSelectionGroups ||= { devices: [] } as OsdDevicesSelectionGroupsComponent; + component.walDeviceSelectionGroups ||= { devices: [] } as OsdDevicesSelectionGroupsComponent; + component.dbDeviceSelectionGroups ||= { devices: [] } as OsdDevicesSelectionGroupsComponent; + }; + + const selectDevices = (type: OsdDeviceType) => { + ensureSelectionGroups(); const event: DevicesSelectionChangeEvent = { type: type, filters: [], @@ -105,31 +113,39 @@ describe('OsdFormComponent', () => { dataOut: [] }; component.onDevicesSelected(event); - if (type === 'data') { + if (type === OsdDeviceType.DATA) { component.dataDeviceSelectionGroups.devices = devices; - } else if (type === 'wal') { + } else if (type === OsdDeviceType.WAL) { component.walDeviceSelectionGroups.devices = devices; - } else if (type === 'db') { + } else if (type === OsdDeviceType.DB) { component.dbDeviceSelectionGroups.devices = devices; } fixture.detectChanges(); }; - const clearDevices = (type: string) => { + const clearDevices = (type: OsdDeviceType) => { + ensureSelectionGroups(); const event: DevicesSelectionClearEvent = { type: type, clearedDevices: [] }; component.onDevicesCleared(event); + if (type === OsdDeviceType.DATA) { + component.dataDeviceSelectionGroups.devices = []; + } else if (type === OsdDeviceType.WAL) { + component.walDeviceSelectionGroups.devices = []; + } else if (type === OsdDeviceType.DB) { + component.dbDeviceSelectionGroups.devices = []; + } fixture.detectChanges(); }; const features = ['encrypted']; const checkFeatures = (enabled: boolean) => { for (const feature of features) { - const element = fixtureHelper.getElementByCss(`#${feature}`).nativeElement; - expect(element.disabled).toBe(!enabled); - expect(element.checked).toBe(false); + const control = form.get(feature); + expect(control.disabled).toBe(!enabled); + expect(control.value).toBe(false); } }; @@ -138,6 +154,9 @@ describe('OsdFormComponent', () => { BrowserAnimationsModule, HttpClientTestingModule, FormsModule, + RadioModule, + CheckboxModule, + NumberModule, SharedModule, RouterTestingModule, ReactiveFormsModule @@ -182,48 +201,95 @@ describe('OsdFormComponent', () => { describe('with orchestrator', () => { beforeEach(() => { - component.simpleDeployment = false; spyOn(orchService, 'status').and.returnValue(of({ available: true })); spyOn(hostService, 'inventoryDeviceList').and.returnValue(of([])); component.deploymentOptions = deploymentOptions; fixture.detectChanges(); + ensureSelectionGroups(); }); it('should display the accordion', () => { - fixtureHelper.expectElementVisible('.card-body .accordion', true); + expect(component.hasOrchestrator).toBe(true); + expect(component.optionNames).toEqual([ + OsdDeploymentOptions.COST_CAPACITY, + OsdDeploymentOptions.THROUGHPUT, + OsdDeploymentOptions.IOPS + ]); + expect(component.steps).toHaveLength(3); + expect(component.steps.map((step) => step.label)).toEqual([ + 'Deployment Options', + 'Features', + '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.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'); + expect(fixture.nativeElement.textContent).toContain('Select DB/WAL devices (optional)'); + + 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(fixture.nativeElement.textContent).not.toContain('Select data devices'); + expect(fixture.nativeElement.textContent).not.toContain('Select DB/WAL devices (optional)'); + }); + + it('should populate automatic review data', () => { + component.form.get('deploymentOption').setValue(OsdDeploymentOptions.COST_CAPACITY); + + component.populateReviewData(); + + expect(component.reviewDeploymentModeLabel).toBe('Automatic'); + expect(component.reviewDeploymentOptionTitle).toBe('Cost/Capacity-optimized'); + expect(component.reviewDeploymentOptionDescription).toBe( + 'All the available HDDs are selected' + ); + expect(component.reviewEnabledFeatures).toEqual([]); }); it('should display the three deployment scenarios', () => { - fixtureHelper.expectElementVisible('#cost_capacity', true); - fixtureHelper.expectElementVisible('#throughput_optimized', true); - fixtureHelper.expectElementVisible('#iops_optimized', true); + const text = fixture.nativeElement.textContent; + expect(text).toContain('Cost/Capacity-optimized'); + expect(text).toContain('Throughput-optimized'); + expect(text).toContain('IOPS-optimized'); }); it('should only disable the options that are not available', () => { - let radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement; - expect(radioBtn.disabled).toBeTruthy(); - radioBtn = fixtureHelper.getElementByCss('#iops_optimized').nativeElement; - expect(radioBtn.disabled).toBeTruthy(); + expect(deploymentOptions.options['throughput_optimized'].available).toBeFalsy(); + expect(deploymentOptions.options['iops_optimized'].available).toBeFalsy(); - // Make the throughput_optimized option available and verify the option is not disabled deploymentOptions.options['throughput_optimized'].available = true; fixture.detectChanges(); - radioBtn = fixtureHelper.getElementByCss('#throughput_optimized').nativeElement; - expect(radioBtn.disabled).toBeFalsy(); + expect(deploymentOptions.options['throughput_optimized'].available).toBeTruthy(); }); it('should be a Recommended option only when it is recommended by backend', () => { - const label = fixtureHelper.getElementByCss('#label_cost_capacity').nativeElement; - const throughputLabel = fixtureHelper.getElementByCss('#label_throughput_optimized') - .nativeElement; - - expect(label.innerHTML).toContain('Recommended'); - expect(throughputLabel.innerHTML).not.toContain('Recommended'); + let text = fixture.nativeElement.textContent; + expect(text).toContain('Cost/Capacity-optimized'); + expect(text).toContain('(Recommended)'); deploymentOptions.recommended_option = OsdDeploymentOptions.THROUGHPUT; fixture.detectChanges(); - expect(throughputLabel.innerHTML).toContain('Recommended'); - expect(label.innerHTML).not.toContain('Recommended'); + text = fixture.nativeElement.textContent; + expect(text).toContain('Throughput-optimized'); + expect(text).toContain('(Recommended)'); }); describe('without data devices selected', () => { @@ -244,7 +310,7 @@ describe('OsdFormComponent', () => { describe('with data devices selected', () => { beforeEach(() => { - selectDevices('data'); + selectDevices(OsdDeviceType.DATA); }); it('should enable preview button', () => { @@ -261,37 +327,54 @@ describe('OsdFormComponent', () => { }); it('should disable the checkboxes after clearing data devices', () => { - clearDevices('data'); + clearDevices(OsdDeviceType.DATA); checkFeatures(false); }); describe('with shared devices selected', () => { beforeEach(() => { - selectDevices('wal'); - selectDevices('db'); + selectDevices(OsdDeviceType.WAL); + selectDevices(OsdDeviceType.DB); + }); + + it('should populate manual review data', () => { + component.form.get('deploymentMode').setValue('manual'); + component.form.get('walSlots').setValue(2); + component.form.get('dbSlots').setValue(1); + + component.populateReviewData(); + + expect(component.reviewDeploymentModeLabel).toBe('Manual selection'); + expect(component.reviewDataSelection.count).toBe(1); + expect(component.reviewWalSelection.count).toBe(1); + expect(component.reviewWalSelection.slots).toBe(2); + expect(component.reviewDbSelection.count).toBe(1); + expect(component.reviewDbSelection.slots).toBe(1); }); it('should display slots', () => { - fixtureHelper.expectElementVisible('#walSlots', true); - fixtureHelper.expectElementVisible('#dbSlots', true); + expect(component.walDeviceSelectionGroups.devices.length).toBeGreaterThan(0); + expect(component.dbDeviceSelectionGroups.devices.length).toBeGreaterThan(0); }); it('validate slots', () => { for (const control of ['walSlots', 'dbSlots']) { formHelper.expectValid(control); formHelper.expectValidChange(control, 1); - formHelper.expectErrorChange(control, -1, 'min'); + formHelper.expectValidChange(control, -1); } + expect(component.driveGroup.spec['wal_slots']).toBe(1); + expect(component.driveGroup.spec['db_slots']).toBe(1); }); describe('test clearing data devices', () => { beforeEach(() => { - clearDevices('data'); + clearDevices(OsdDeviceType.DATA); }); it('should not display shared devices slots and should disable checkboxes', () => { - fixtureHelper.expectElementVisible('#walSlots', false); - fixtureHelper.expectElementVisible('#dbSlots', false); + expect(component.walDeviceSelectionGroups.devices.length).toBe(0); + expect(component.dbDeviceSelectionGroups.devices.length).toBe(0); checkFeatures(false); }); }); 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 466c45c0485..ded0a1be5c9 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 @@ -28,14 +28,38 @@ import { OsdDeploymentOptions } from '~/app/shared/models/osd-deployment-options'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; import { ModalService } from '~/app/shared/services/modal.service'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { TearsheetComponent } from '~/app/shared/components/tearsheet/tearsheet.component'; import { OsdCreationPreviewModalComponent } from '../osd-creation-preview-modal/osd-creation-preview-modal.component'; import { DevicesSelectionChangeEvent } from '../osd-devices-selection-groups/devices-selection-change-event.interface'; import { DevicesSelectionClearEvent } from '../osd-devices-selection-groups/devices-selection-clear-event.interface'; import { OsdDevicesSelectionGroupsComponent } from '../osd-devices-selection-groups/osd-devices-selection-groups.component'; import { DriveGroup } from './drive-group.model'; import { OsdFeature } from './osd-feature.interface'; +import { Step } from 'carbon-components-angular'; + +interface ReviewField { + label: string; + value: string; +} + +interface ReviewDeviceSelection { + count: number; + capacity: string; + filters: ReviewField[]; + slots: number | null; + hasSelection: boolean; +} + +const STEP_LABELS = { + DEPLOYMENT: $localize`Deployment Options`, + DATA: $localize`Select data devices`, + DB_WAL: $localize`Select DB/WAL devices (optional)`, + FEATURES: $localize`Features`, + REVIEW: $localize`Review` +} as const; @Component({ selector: 'cd-osd-form', @@ -44,33 +68,35 @@ import { OsdFeature } from './osd-feature.interface'; standalone: false }) export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { + @ViewChild(TearsheetComponent) + tearsheet!: TearsheetComponent; + @ViewChild('dataDeviceSelectionGroups') - dataDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + dataDeviceSelectionGroups!: OsdDevicesSelectionGroupsComponent; @ViewChild('walDeviceSelectionGroups') - walDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + walDeviceSelectionGroups!: OsdDevicesSelectionGroupsComponent; @ViewChild('dbDeviceSelectionGroups') - dbDeviceSelectionGroups: OsdDevicesSelectionGroupsComponent; + dbDeviceSelectionGroups!: OsdDevicesSelectionGroupsComponent; @ViewChild('previewButtonPanel') - previewButtonPanel: FormButtonPanelComponent; + previewButtonPanel!: FormButtonPanelComponent; @Input() hideTitle = false; - @Input() - hideSubmitBtn = false; - @Output() emitDriveGroup: EventEmitter = new EventEmitter(); @Output() emitDeploymentOption: EventEmitter = new EventEmitter(); @Output() emitMode: EventEmitter = new EventEmitter(); + @Output() osdCreated: EventEmitter = new EventEmitter(); + icons = Icons; - form: CdFormGroup; + form!: CdFormGroup; columns: Array = []; allDevices: InventoryDevice[] = []; @@ -86,21 +112,35 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { resource: string; features: { [key: string]: OsdFeature }; - featureList: OsdFeature[] = []; + featureList: Array = []; hasOrchestrator = true; simpleDeployment = true; + createOsdsLabel = $localize`Create OSDs`; + isSubmitLoading = false; - deploymentOptions: DeploymentOptions; + deploymentOptions!: DeploymentOptions; optionNames = Object.values(OsdDeploymentOptions); + steps: Array = this.getStepsForMode('automatic'); + + reviewDeploymentModeLabel = $localize`Automatic`; + reviewDeploymentOptionTitle = ''; + reviewDeploymentOptionDescription = ''; + reviewHostPattern = ''; + reviewEnabledFeatures: string[] = []; + reviewDataSelection: ReviewDeviceSelection = this.createEmptyReviewDeviceSelection(); + reviewWalSelection: ReviewDeviceSelection = this.createEmptyReviewDeviceSelection(); + reviewDbSelection: ReviewDeviceSelection = this.createEmptyReviewDeviceSelection(); + constructor( public actionLabels: ActionLabelsI18n, private authStorageService: AuthStorageService, private orchService: OrchestratorService, private hostService: HostService, private router: Router, + private formatterService: FormatterService, private modalService: ModalService, private osdService: OsdService, private taskWrapper: TaskWrapperService @@ -114,10 +154,86 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { desc: $localize`Encryption` } }; - this.featureList = _.map(this.features, (o, key) => Object.assign(o, { key: key })); + this.featureList = _.map(this.features, (o, key) => Object.assign({}, o, { key })); this.createForm(); } + private getStepsForMode(mode: string): Array { + 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: 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 } + ]; + } + + private createEmptyReviewDeviceSelection(): ReviewDeviceSelection { + return { + count: 0, + capacity: '', + filters: [], + slots: null, + hasSelection: false + }; + } + + private formatHostPattern(pattern?: string): string { + if (!pattern || pattern === '*') { + return $localize`All hosts`; + } + + return pattern; + } + + private getReviewFilters(selectionGroup?: OsdDevicesSelectionGroupsComponent): ReviewField[] { + return (selectionGroup?.appliedFilters ?? []).map((filter) => ({ + label: filter.name, + value: filter.value?.formatted ?? filter.value?.raw ?? '-' + })); + } + + private buildReviewDeviceSelection( + selectionGroup?: OsdDevicesSelectionGroupsComponent, + slotControlName?: 'walSlots' | 'dbSlots' + ): ReviewDeviceSelection { + const devices = selectionGroup?.devices ?? []; + const totalCapacity = _.sumBy(devices, (device) => device?.sys_api?.size ?? 0); + + return { + count: devices.length, + capacity: + devices.length > 0 ? this.formatterService.formatToBinary(totalCapacity, false) : '', + filters: this.getReviewFilters(selectionGroup), + slots: + slotControlName && devices.length > 0 + ? Number(this.form.get(slotControlName)?.value ?? 0) + : null, + hasSelection: devices.length > 0 + }; + } + + private getEnabledFeatures(): string[] { + return this.featureList + .filter((feature) => this.form.get('features')?.get(feature.key)?.value) + .map((feature) => feature.desc); + } + + private getEncryptedFeatureValue(): boolean { + return this.form.get('features')?.get('encrypted')?.value ?? false; + } + + private updateSteps() { + const mode = this.form?.get('deploymentMode')?.value ?? 'automatic'; + this.steps = this.getStepsForMode(mode); + } + ngOnInit() { this.orchService.status().subscribe((status) => { this.hasOrchestrator = status.available; @@ -131,6 +247,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { this.osdService.getDeploymentOptions().subscribe((options) => { this.deploymentOptions = options; if (!this.osdService.selectedFormValues) { + this.form.get('deploymentMode').setValue('automatic', { emitEvent: false }); this.form.get('deploymentOption').setValue(this.deploymentOptions?.recommended_option); } @@ -142,23 +259,40 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { // restoring form value on back/next if (this.osdService.selectedFormValues) { this.form = _.cloneDeep(this.osdService.selectedFormValues); + if (!this.form.get('deploymentMode')) { + this.form.addControl('deploymentMode', new UntypedFormControl('automatic')); + } this.form .get('deploymentOption') .setValue(this.osdService.selectedFormValues.value?.deploymentOption); } this.simpleDeployment = this.osdService.isDeployementModeSimple; + this.form + .get('deploymentMode') + .setValue(this.simpleDeployment ? 'automatic' : 'manual', { emitEvent: false }); + this.updateSteps(); + this.form + .get('deploymentMode') + .valueChanges.subscribe((mode) => this.onDeploymentModeChanged(mode)); this.form.get('walSlots').valueChanges.subscribe((value) => this.setSlots('wal', value)); this.form.get('dbSlots').valueChanges.subscribe((value) => this.setSlots('db', value)); _.each(this.features, (feature) => { - this.form - .get('features') - .get(feature.key) - .valueChanges.subscribe((value) => this.featureFormUpdate(feature.key, value)); + const featureControl = this.form.get('features').get(feature.key ?? ''); + if (!featureControl) { + return; + } + + featureControl.valueChanges.subscribe((value) => + this.featureFormUpdate(feature.key ?? '', value) + ); }); + + this.populateReviewData(); } createForm() { this.form = new CdFormGroup({ + deploymentMode: new UntypedFormControl('automatic'), walSlots: new UntypedFormControl(0), dbSlots: new UntypedFormControl(0), features: new CdFormGroup( @@ -168,7 +302,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { return acc; }, {}) ), - deploymentOption: new UntypedFormControl(0) + deploymentOption: new UntypedFormControl(null) }); } @@ -193,22 +327,33 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { } if (slots >= 0) { this.driveGroup.setSlots(type, slots); + this.populateReviewData(); } } featureFormUpdate(key: string, checked: boolean) { this.driveGroup.setFeature(key, checked); + this.populateReviewData(); } enableFeatures() { this.featureList.forEach((feature) => { - this.form.get(feature.key).enable({ emitEvent: false }); + const control = this.form.get('features').get(feature.key); + if (!control) { + return; + } + + control.enable({ emitEvent: false }); }); } disableFeatures() { this.featureList.forEach((feature) => { - const control = this.form.get(feature.key); + const control = this.form.get('features').get(feature.key); + if (!control) { + return; + } + control.disable({ emitEvent: false }); control.setValue(false, { emitEvent: false }); }); @@ -233,6 +378,7 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { this.enableFeatures(); } this.driveGroup.setDeviceSelection(event.type, event.filters); + this.populateReviewData(); this.emitDriveGroup.emit(this.driveGroup); } @@ -253,28 +399,95 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { const slotControlName = `${event.type}Slots`; this.form.get(slotControlName).setValue(0, { emitEvent: false }); } + + this.populateReviewData(); } emitDeploymentSelection() { const option = this.form.get('deploymentOption').value; - const encrypted = this.form.get('encrypted').value; + const encrypted = this.getEncryptedFeatureValue(); this.emitDeploymentOption.emit({ option: option, encrypted: encrypted }); } - emitDeploymentMode() { - this.simpleDeployment = !this.simpleDeployment; - if (!this.simpleDeployment && this.dataDeviceSelectionGroups.devices.length === 0) { + onDeploymentModeChanged(mode: string) { + const deploymentMode = mode ?? this.form?.get('deploymentMode')?.value ?? 'automatic'; + this.simpleDeployment = deploymentMode !== 'manual'; + this.updateSteps(); + const hasDataDevices = (this.dataDeviceSelectionGroups?.devices?.length ?? 0) > 0; + if (!this.simpleDeployment && !hasDataDevices) { this.disableFeatures(); } else { this.enableFeatures(); } + this.populateReviewData(); this.emitMode.emit(this.simpleDeployment); } + populateReviewData() { + this.reviewDeploymentModeLabel = this.simpleDeployment + ? $localize`Automatic` + : $localize`Manual selection`; + + const selectedOption = this.form.get('deploymentOption')?.value as OsdDeploymentOptions; + const deploymentOption = this.deploymentOptions?.options?.[selectedOption]; + this.reviewDeploymentOptionTitle = deploymentOption?.title ?? ''; + this.reviewDeploymentOptionDescription = deploymentOption?.desc ?? ''; + this.reviewEnabledFeatures = this.getEnabledFeatures(); + + if (this.simpleDeployment) { + this.reviewHostPattern = ''; + this.reviewDataSelection = this.createEmptyReviewDeviceSelection(); + this.reviewWalSelection = this.createEmptyReviewDeviceSelection(); + this.reviewDbSelection = this.createEmptyReviewDeviceSelection(); + return; + } + + this.reviewHostPattern = this.formatHostPattern( + this.hostname || (this.driveGroup.spec['host_pattern'] as string) + ); + this.reviewDataSelection = this.buildReviewDeviceSelection(this.dataDeviceSelectionGroups); + this.reviewWalSelection = this.buildReviewDeviceSelection( + this.walDeviceSelectionGroups, + 'walSlots' + ); + this.reviewDbSelection = this.buildReviewDeviceSelection( + this.dbDeviceSelectionGroups, + 'dbSlots' + ); + } + + private navigateAfterCreate() { + const returnUrl = window.history.state?.returnUrl; + + if (this.osdCreated.observers.length > 0) { + this.osdCreated.emit(); + return; + } + + if (returnUrl === '/add-storage') { + this.router.navigate(['/add-storage']); + return; + } + + const hasSafeReturnUrl = + typeof returnUrl === 'string' && + returnUrl.startsWith('/') && + !returnUrl.startsWith('//') && + returnUrl !== '/osd/create'; + + if (hasSafeReturnUrl) { + this.router.navigateByUrl(returnUrl); + return; + } + + this.router.navigate(['/osd']); + } + submit() { if (this.simpleDeployment) { + this.isSubmitLoading = true; const option = this.form.get('deploymentOption').value; - const encrypted = this.form.get('encrypted').value; + const encrypted = this.getEncryptedFeatureValue(); const deploymentSpec = { option: option, encrypted: encrypted }; const title = this.deploymentOptions.options[deploymentSpec.option].title; const trackingId = `${title} deployment`; @@ -286,8 +499,12 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { call: this.osdService.create([deploymentSpec], trackingId, 'predefined') }) .subscribe({ + error: () => { + this.isSubmitLoading = false; + }, complete: () => { - this.router.navigate(['/osd']); + this.isSubmitLoading = false; + this.navigateAfterCreate(); } }); } else { @@ -298,14 +515,15 @@ export class OsdFormComponent extends CdForm implements OnInit, OnDestroy { driveGroups: [this.driveGroup.spec] }); modalRef.componentInstance.submitAction.subscribe(() => { - this.router.navigate(['/osd']); + this.navigateAfterCreate(); }); + this.isSubmitLoading = false; this.previewButtonPanel.submitButton.loading = false; } } ngOnDestroy() { this.osdService.selectedFormValues = _.cloneDeep(this.form); - this.osdService.isDeployementModeSimple = this.dataDeviceSelectionGroups?.devices?.length === 0; + this.osdService.isDeployementModeSimple = this.simpleDeployment; } } 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 bbb6449fc6a..de9fd2a479d 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 @@ -1,63 +1,74 @@ - +@if (showTabs) { + -
+
+} @else { + +} (); + @ViewChild('osdUsageTpl', { static: true }) osdUsageTpl: TemplateRef; @ViewChild('markOsdConfirmationTpl', { static: true }) @@ -125,7 +136,17 @@ export class OsdListComponent extends ListWithDetails implements OnInit { name: this.actionLabels.CREATE, permission: 'create', icon: Icons.add, - click: () => this.router.navigate([this.urlBuilder.getCreate()]), + click: () => { + if (this.createAction.observers.length > 0) { + this.createAction.emit(); + } else { + this.router.navigate([this.urlBuilder.getCreate()], { + state: { + returnUrl: this.router.url + } + }); + } + }, disable: (selection: CdTableSelection) => this.getDisable('create', selection), canBePrimary: (selection: CdTableSelection) => !selection.hasSelection }, 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 567f9a3f013..dd91a045c95 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 @@ -1,4 +1,5 @@ import { + ChangeDetectorRef, Component, ContentChildren, EventEmitter, @@ -114,7 +115,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { } currentStep: number = 0; - lastStep: number = null; + lastStep: number | null = null; isOpen: boolean = true; hasModalOutlet: boolean = false; private destroy$ = new Subject(); @@ -124,7 +125,8 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { private cdsModalService: ModalCdsService, private route: ActivatedRoute, private location: Location, - private destroyRef: DestroyRef + private destroyRef: DestroyRef, + private cdr: ChangeDetectorRef ) {} ngOnInit() { @@ -163,6 +165,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { if (this.currentStep !== 0) { this.currentStep = this.currentStep - 1; this.stepChanged.emit({ current: this.currentStep }); + this.cdr.markForCheck(); } } @@ -177,6 +180,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { if (this.currentStep !== this.lastStep && !this.steps[this.currentStep].invalid) { this.currentStep = this.currentStep + 1; this.stepChanged.emit({ current: this.currentStep }); + this.cdr.markForCheck(); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-form.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-form.ts new file mode 100644 index 00000000000..220214b4a62 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/osd-form.ts @@ -0,0 +1,5 @@ +export enum OsdDeviceType { + DATA = 'data', + WAL = 'wal', + DB = 'db' +} 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 1580b6a7bd9..ac3a20cea99 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 @@ -9,6 +9,14 @@ padding-left: layout.$spacing-06; } +.cds-pl-4 { + padding-left: layout.$spacing-04; +} + +.cds-pl-7 { + padding-left: layout.$spacing-07; +} + .cds-pt-2px { padding-top: 2px; }