From: Pedro Gonzalez Gomez Date: Thu, 20 Nov 2025 14:09:03 +0000 (+0100) Subject: mgr/dashboard: Cephfs Mirroring Wizard X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=e32d36429969d21e29eac7f97d2add08fd047803;p=ceph.git mgr/dashboard: Cephfs Mirroring Wizard Fixes: https://tracker.ceph.com/issues/74200 Signed-off-by: Dnyaneshwari Talwekar --- 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 34cd097b40a2..32d910a031ec 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 @@ -61,6 +61,7 @@ import { SmbOverviewComponent } from './ceph/smb/smb-overview/smb-overview.compo import { MultiClusterFormComponent } from './ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component'; import { CephfsMirroringListComponent } from './ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component'; import { NotificationsPageComponent } from './core/navigation/notification-panel/notifications-page/notifications-page.component'; +import { CephfsMirroringWizardComponent } from './ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component'; @Injectable() export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver { @@ -429,6 +430,11 @@ const routes: Routes = [ component: CephfsMirroringListComponent, data: { breadcrumbs: 'File/Mirroring' } }, + { + path: `mirroring/${URLVerbs.CREATE}`, + component: CephfsMirroringWizardComponent, + data: { breadcrumbs: ActionLabels.CREATE } + }, { path: 'nfs', canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService], diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts index d269b6aa912e..700211e68c45 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/ceph.module.ts @@ -8,6 +8,7 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { NfsModule } from './nfs/nfs.module'; import { PerformanceCounterModule } from './performance-counter/performance-counter.module'; import { SmbModule } from './smb/smb.module'; +import { TilesModule } from 'carbon-components-angular'; @NgModule({ imports: [ @@ -18,7 +19,8 @@ import { SmbModule } from './smb/smb.module'; CephfsModule, NfsModule, SmbModule, - SharedModule + SharedModule, + TilesModule ], declarations: [] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html index 848dc1dc57d7..4559a4d68af2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html @@ -1,12 +1,17 @@ + + (fetchData)="loadDaemonStatus()"> + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts index c207580975d0..7d9e04bb6a38 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts @@ -10,6 +10,9 @@ import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data import { CdTableAction } from '~/app/shared/models/cd-table-action'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; +import { Permission } from '~/app/shared/models/permissions'; export const MIRRORING_PATH = 'cephfs/mirroring'; @Component({ @@ -28,8 +31,16 @@ export class CephfsMirroringListComponent implements OnInit { daemonStatus$: Observable; context: CdTableFetchDataContext; tableActions: CdTableAction[]; + permission: Permission; - constructor(public actionLabels: ActionLabelsI18n, private cephfsService: CephfsService) {} + constructor( + public actionLabels: ActionLabelsI18n, + private authStorageService: AuthStorageService, + private cephfsService: CephfsService, + private urlBuilder: URLBuilderService + ) { + this.permission = this.authStorageService.getPermissions().cephfs; + } ngOnInit() { this.columns = [ @@ -44,6 +55,15 @@ export class CephfsMirroringListComponent implements OnInit { { name: $localize`Snapshot directories`, prop: 'directory_count', flexGrow: 1 } ]; + const createAction: CdTableAction = { + permission: 'create', + icon: Icons.add, + routerLink: () => this.urlBuilder.getCreate(), + name: this.actionLabels.CREATE, + canBePrimary: (selection: CdTableSelection) => !selection.hasSelection + }; + + this.tableActions = [createAction]; this.daemonStatus$ = this.subject$.pipe( switchMap(() => this.cephfsService.listDaemonStatus()?.pipe( @@ -76,7 +96,6 @@ export class CephfsMirroringListComponent implements OnInit { } }); }); - return of(result); }), catchError(() => { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts new file mode 100644 index 000000000000..e8db5f9cf48c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard-step.enum.ts @@ -0,0 +1,17 @@ +export enum StepTitles { + ChooseMirrorPeerRole = 'Choose mirror peer role', + SelectFilesystem = 'Select filesystem', + CreateOrSelectEntity = 'Create or select entity', + GenerateBootstrapToken = 'Generate bootstrap token', + Review = 'Review' +} + +export const STEP_TITLES_MIRRORING_CONFIGURED = [ + StepTitles.ChooseMirrorPeerRole, + StepTitles.SelectFilesystem, + StepTitles.CreateOrSelectEntity, + StepTitles.GenerateBootstrapToken +]; + +export const LOCAL_ROLE = 'local'; +export const REMOTE_ROLE = 'remote'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html new file mode 100644 index 000000000000..5ac776029b22 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html @@ -0,0 +1,111 @@ + + +
+
+
Choose mirror peer role
+

Select how the cluster will participate in the CephFS Mirroring relationship.

+
+ +
+
+ + + +
+
Configure local peer
+
+ This cluster will act as the initiating peer and send snapshots to a remote + peer. +
+
    + @for (item of sourceList; track $index) { +
  • → {{ item }}
  • + } +
+
+
+
+
+
+ + + +
+
Configure remote peer
+
+ A remote cluster will act as the receiving peer and store replicated snapshots. +
+
    + @for (item of targetList; track $index) { +
  • → {{ item }}
  • + } +
+
+
+
+
+
+ + @if (form.get('localRole')?.value !== LOCAL_ROLE && showMessage) { + +
+
About Remote Peer Setup
+
+ As a remote peer, this cluster prepares to receive mirrored data from an initiating + cluster. The setup includes environment validation, enabling filesystem mirroring, + creating required Ceph users, and generating a bootstrap token. +
+
What happens next:
+
    +
  • Environment validation
  • +
  • Ceph user creation
  • +
  • Filesystem mirroring activation
  • +
  • Bootstrap token generation
  • +
+
+
+ } +
+
+ + + +
Test 1
+
+ + + +
Test 2
+
+ + + +
+

Test3

+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss new file mode 100644 index 000000000000..b7a07e3a90f4 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.scss @@ -0,0 +1,7 @@ +:host ::ng-deep cd-alert-panel.mirroring-alert cds-actionable-notification { + max-width: 77% !important; +} + +.list-disc { + list-style-type: disc; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.spec.ts new file mode 100644 index 000000000000..67397af8de5d --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.spec.ts @@ -0,0 +1,101 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard.component'; +import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; +import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; +import { Router } from '@angular/router'; +import { BehaviorSubject } from 'rxjs'; +import { + STEP_TITLES_MIRRORING_CONFIGURED, + LOCAL_ROLE, + REMOTE_ROLE +} from './cephfs-mirroring-wizard-step.enum'; +import { WizardStepModel } from '~/app/shared/models/wizard-steps'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { RadioModule } from 'carbon-components-angular'; + +describe('CephfsMirroringWizardComponent', () => { + let component: CephfsMirroringWizardComponent; + let fixture: ComponentFixture; + let wizardStepsService: jest.Mocked; + let router: jest.Mocked; + + const mockSteps: WizardStepModel[] = [ + { stepIndex: 0, isComplete: false }, + { stepIndex: 1, isComplete: false } + ]; + + beforeEach(async () => { + wizardStepsService = ({ + setTotalSteps: jest.fn(), + setCurrentStep: jest.fn(), + steps$: new BehaviorSubject(mockSteps) + } as unknown) as jest.Mocked; + + router = ({ + navigate: jest.fn() + } as unknown) as jest.Mocked; + + await TestBed.configureTestingModule({ + imports: [ReactiveFormsModule, RadioModule], + declarations: [CephfsMirroringWizardComponent], + providers: [ + FormBuilder, + { provide: WizardStepsService, useValue: wizardStepsService }, + { provide: Router, useValue: router } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + fixture = TestBed.createComponent(CephfsMirroringWizardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create the component', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize wizard steps on ngOnInit', () => { + expect(wizardStepsService.setTotalSteps).toHaveBeenCalledWith( + STEP_TITLES_MIRRORING_CONFIGURED.length + ); + + expect(component.steps.length).toBe(STEP_TITLES_MIRRORING_CONFIGURED.length); + }); + + it('should navigate to step when goToStep is called', () => { + component.goToStep(mockSteps[0]); + + expect(wizardStepsService.setCurrentStep).toHaveBeenCalledWith(mockSteps[0]); + }); + + it('should initialize form with local role selected', () => { + expect(component.form.value).toEqual({ + localRole: LOCAL_ROLE, + remoteRole: null + }); + }); + + it('should update form on local role change', () => { + component.onLocalRoleChange(); + + expect(component.form.value).toEqual({ + localRole: LOCAL_ROLE, + remoteRole: null + }); + }); + + it('should update form on remote role change', () => { + component.onRemoteRoleChange(); + + expect(component.form.value).toEqual({ + localRole: null, + remoteRole: REMOTE_ROLE + }); + }); + + it('should navigate to mirroring list on cancel', () => { + component.onCancel(); + expect(router.navigate).toHaveBeenCalledWith(['/cephfs/mirroring']); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.ts new file mode 100644 index 000000000000..c24710113ea6 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.ts @@ -0,0 +1,82 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { Step } from 'carbon-components-angular'; +import { Router } from '@angular/router'; +import { + STEP_TITLES_MIRRORING_CONFIGURED, + LOCAL_ROLE, + REMOTE_ROLE +} from './cephfs-mirroring-wizard-step.enum'; +import { WizardStepsService } from '~/app/shared/services/wizard-steps.service'; +import { WizardStepModel } from '~/app/shared/models/wizard-steps'; +import { FormBuilder, FormGroup } from '@angular/forms'; +@Component({ + selector: 'cd-cephfs-mirroring-wizard', + templateUrl: './cephfs-mirroring-wizard.component.html', + standalone: false, + styleUrls: ['./cephfs-mirroring-wizard.component.scss'] +}) +export class CephfsMirroringWizardComponent implements OnInit { + steps: Step[] = []; + title: string = $localize`Create new CephFS Mirroring`; + description: string = $localize`Configure a new mirroring relationship between clusters`; + form: FormGroup; + showMessage: boolean = true; + + LOCAL_ROLE = LOCAL_ROLE; + REMOTE_ROLE = REMOTE_ROLE; + + private wizardStepsService = inject(WizardStepsService); + private fb = inject(FormBuilder); + private router = inject(Router); + + sourceList: string[] = [ + $localize`Sends data to remote clusters`, + $localize`Requires bootstrap token from target`, + $localize`Manages snapshot schedules` + ]; + + targetList: string[] = [ + $localize`Receives data from source clusters`, + $localize`Generates bootstrap token`, + $localize`Stores replicated snapshots` + ]; + + constructor() { + this.form = this.fb.group({ + localRole: [LOCAL_ROLE], + remoteRole: [null] + }); + } + + ngOnInit() { + this.wizardStepsService.setTotalSteps(STEP_TITLES_MIRRORING_CONFIGURED.length); + + const stepsData = this.wizardStepsService.steps$.value; + this.steps = STEP_TITLES_MIRRORING_CONFIGURED.map((title, index) => ({ + label: title, + onClick: () => this.goToStep(stepsData[index]) + })); + } + + goToStep(step: WizardStepModel) { + if (step) { + this.wizardStepsService.setCurrentStep(step); + } + } + + onLocalRoleChange() { + this.form.patchValue({ localRole: LOCAL_ROLE, remoteRole: null }); + this.showMessage = false; + } + + onRemoteRoleChange() { + this.form.patchValue({ localRole: null, remoteRole: REMOTE_ROLE }); + this.showMessage = true; + } + + onSubmit() {} + + onCancel() { + this.router.navigate(['/cephfs/mirroring']); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index afe99c867cd4..548cb4c9821d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -31,7 +31,6 @@ import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapsh import { CephfsSnapshotscheduleFormComponent } from './cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component'; import { CephfsMountDetailsComponent } from './cephfs-mount-details/cephfs-mount-details.component'; import { CephfsAuthModalComponent } from './cephfs-auth-modal/cephfs-auth-modal.component'; -import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component'; import { ButtonModule, CheckboxModule, @@ -49,13 +48,16 @@ import { SelectModule, TimePickerModule, TreeviewModule, - TabsModule + TabsModule, + RadioModule } from 'carbon-components-angular'; import AddIcon from '@carbon/icons/es/add/32'; import LaunchIcon from '@carbon/icons/es/launch/32'; import Close from '@carbon/icons/es/close/32'; import Trash from '@carbon/icons/es/trash-can/32'; +import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component'; +import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard/cephfs-mirroring-wizard.component'; @NgModule({ imports: [ @@ -87,7 +89,8 @@ import Trash from '@carbon/icons/es/trash-can/32'; ComboBoxModule, IconModule, BaseChartDirective, - TabsModule + TabsModule, + RadioModule ], declarations: [ CephfsDetailComponent, @@ -108,7 +111,8 @@ import Trash from '@carbon/icons/es/trash-can/32'; CephfsSubvolumeSnapshotsFormComponent, CephfsMountDetailsComponent, CephfsAuthModalComponent, - CephfsMirroringListComponent + CephfsMirroringListComponent, + CephfsMirroringWizardComponent ], providers: [provideCharts(withDefaultRegisterables())] }) 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 c2ac38a35ccc..e091b203a3d2 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 @@ -63,7 +63,8 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { @Input() submitButtonLoadingLabel: string = $localize`Creating`; @Input() isSubmitLoading: boolean = true; - @Output() submitRequested = new EventEmitter(); + @Output() submitRequested = new EventEmitter(); + @Output() closeRequested = new EventEmitter(); @ContentChildren(TearsheetStepComponent) stepContents!: QueryList; @@ -105,6 +106,7 @@ export class TearsheetComponent implements OnInit, AfterViewInit, OnDestroy { } closeWideTearsheet() { + this.closeRequested.emit(); this.isOpen = false; if (this.hasModalOutlet) { this.location.back(); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts index a918ff01d333..97c5da60e5f5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts @@ -17,6 +17,7 @@ import { FormlyObjectTypeComponent } from './forms/crud-form/formly-object-type/ import { FormlyInputTypeComponent } from './forms/crud-form/formly-input-type/formly-input-type.component'; import { FormlyTextareaTypeComponent } from './forms/crud-form/formly-textarea-type/formly-textarea-type.component'; import { BlockUIModule, BlockUIService } from 'ng-block-ui'; +import { TilesModule } from 'carbon-components-angular'; @NgModule({ imports: [ @@ -39,7 +40,7 @@ import { BlockUIModule, BlockUIService } from 'ng-block-ui'; BlockUIModule.forRoot() ], declarations: [FormlyTextareaTypeComponent], - exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule], + exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule, TilesModule], providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper, BlockUIService] }) export class SharedModule {} 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 12abe3888352..b4eb98629060 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 @@ -12,10 +12,22 @@ 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; +} + +.cds-mb-1 { + margin-bottom: layout.$spacing-01; +} + .cds-mb-2 { margin-bottom: layout.$spacing-02; } @@ -28,6 +40,18 @@ margin-bottom: layout.$spacing-05; } +.cds-mb-6 { + margin-bottom: layout.$spacing-06; +} + +.cds-mt-3 { + margin-top: layout.$spacing-03; +} + .cds-mt-5 { margin-top: layout.$spacing-05; } + +.cds-mt-6 { + margin-top: layout.$spacing-06; +}