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 {
component: CephfsMirroringListComponent,
data: { breadcrumbs: 'File/Mirroring' }
},
+ {
+ path: `mirroring/${URLVerbs.CREATE}`,
+ component: CephfsMirroringWizardComponent,
+ data: { breadcrumbs: ActionLabels.CREATE }
+ },
{
path: 'nfs',
canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService],
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: [
CephfsModule,
NfsModule,
SmbModule,
- SharedModule
+ SharedModule,
+ TilesModule
],
declarations: []
})
<ng-container *ngIf="daemonStatus$ | async as daemonStatus">
+
<cd-table
+ #table
[data]="daemonStatus"
[columns]="columns"
+ columnMode="flex"
selectionType="single"
- [hasDetails]="false"
- (setExpandedRow)="setExpandedRow($event)"
- (fetchData)="loadDaemonStatus($event)"
(updateSelection)="updateSelection($event)"
- >
+ (fetchData)="loadDaemonStatus()">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
</cd-table>
</ng-container>
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({
daemonStatus$: Observable<MirroringRow[]>;
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 = [
{ 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(
}
});
});
-
return of(result);
}),
catchError(() => {
--- /dev/null
+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';
--- /dev/null
+<cd-tearsheet
+ [steps]="steps"
+ [title]="title"
+ [description]="description"
+ (submitRequested)="onSubmit()"
+ (closeRequested)="onCancel()">
+ <cd-tearsheet-step>
+ <form [formGroup]="form">
+ <div>
+ <div class="cds--type-heading-03"
+ i18n>Choose mirror peer role</div>
+ <p i18n>Select how the cluster will participate in the CephFS Mirroring relationship.</p>
+ </div>
+
+ <div cdsStack="horizontal">
+ <div class="cds-mr-5">
+ <cds-tile>
+ <cds-radio-group formControlName="localRole">
+ <cds-radio
+ [value]="LOCAL_ROLE"
+ [checked]="form.get('localRole')?.value === LOCAL_ROLE"
+ (click)="onLocalRoleChange()">
+ <div>
+ <div class="cds--type-heading-compact-02"
+ i18n>Configure local peer</div>
+ <div class="cds--type-label-01 cds-mt-3"
+ i18n>
+ This cluster will act as the initiating peer and send snapshots to a remote
+ peer.
+ </div>
+ <ul class="cds--type-body-compact-01 cds-mt-6">
+ @for (item of sourceList; track $index) {
+ <li class="cds-mb-6 cds-mt-3">→ {{ item }}</li>
+ }
+ </ul>
+ </div>
+ </cds-radio>
+ </cds-radio-group>
+ </cds-tile>
+ </div>
+ <cds-tile>
+ <cds-radio-group formControlName="remoteRole">
+ <cds-radio
+ [value]="REMOTE_ROLE"
+ [checked]="form.get('remoteRole')?.value === REMOTE_ROLE"
+ (click)="onRemoteRoleChange()">
+ <div>
+ <div class="cds--type-heading-compact-02"
+ i18n>Configure remote peer</div>
+ <div class="cds--type-label-01 cds-mt-3"
+ i18n>
+ A remote cluster will act as the receiving peer and store replicated snapshots.
+ </div>
+ <ul class="cds--type-body-compact-01 cds-mt-6">
+ @for (item of targetList; track $index) {
+ <li class="cds-mb-6 cds-mt-3">→ {{ item }}</li>
+ }
+ </ul>
+ </div>
+ </cds-radio>
+ </cds-radio-group>
+ </cds-tile>
+ </div>
+
+ @if (form.get('localRole')?.value !== LOCAL_ROLE && showMessage) {
+ <cd-alert-panel
+ type="info"
+ spacingClass="mb-3 mt-3"
+ dismissible="true"
+ (dismissed)="showMessage = false"
+ class="mirroring-alert">
+ <div>
+ <div class="cds--type-heading-compact-01 cds-mb-2"
+ i18n>About Remote Peer Setup</div>
+ <div class="cds--type-body-compact-01 cds-mb-3"
+ i18n>
+ 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.
+ </div>
+ <div class="cds--type-heading-compact-01 cds-mb-1 cds-mt-6"
+ i18n>What happens next:</div>
+ <ul class="list-disc cds-ml-5 cds--type-body-compact-01">
+ <li i18n>Environment validation</li>
+ <li i18n>Ceph user creation</li>
+ <li i18n>Filesystem mirroring activation</li>
+ <li i18n>Bootstrap token generation</li>
+ </ul>
+ </div>
+ </cd-alert-panel>
+ }
+ </form>
+ </cd-tearsheet-step>
+
+ <!-- Step 1 -->
+ <cd-tearsheet-step>
+ <div>Test 1</div>
+ </cd-tearsheet-step>
+
+ <!-- Step 2 -->
+ <cd-tearsheet-step>
+ <div>Test 2</div>
+ </cd-tearsheet-step>
+
+ <!-- Step 3 -->
+ <cd-tearsheet-step>
+ <div>
+ <p>Test3</p>
+ </div>
+ </cd-tearsheet-step>
+</cd-tearsheet>
--- /dev/null
+:host ::ng-deep cd-alert-panel.mirroring-alert cds-actionable-notification {
+ max-width: 77% !important;
+}
+
+.list-disc {
+ list-style-type: disc;
+}
--- /dev/null
+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<CephfsMirroringWizardComponent>;
+ let wizardStepsService: jest.Mocked<WizardStepsService>;
+ let router: jest.Mocked<Router>;
+
+ const mockSteps: WizardStepModel[] = [
+ { stepIndex: 0, isComplete: false },
+ { stepIndex: 1, isComplete: false }
+ ];
+
+ beforeEach(async () => {
+ wizardStepsService = ({
+ setTotalSteps: jest.fn(),
+ setCurrentStep: jest.fn(),
+ steps$: new BehaviorSubject<WizardStepModel[]>(mockSteps)
+ } as unknown) as jest.Mocked<WizardStepsService>;
+
+ router = ({
+ navigate: jest.fn()
+ } as unknown) as jest.Mocked<Router>;
+
+ 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']);
+ });
+});
--- /dev/null
+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']);
+ }
+}
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,
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: [
ComboBoxModule,
IconModule,
BaseChartDirective,
- TabsModule
+ TabsModule,
+ RadioModule
],
declarations: [
CephfsDetailComponent,
CephfsSubvolumeSnapshotsFormComponent,
CephfsMountDetailsComponent,
CephfsAuthModalComponent,
- CephfsMirroringListComponent
+ CephfsMirroringListComponent,
+ CephfsMirroringWizardComponent
],
providers: [provideCharts(withDefaultRegisterables())]
})
@Input() submitButtonLoadingLabel: string = $localize`Creating`;
@Input() isSubmitLoading: boolean = true;
- @Output() submitRequested = new EventEmitter<any[]>();
+ @Output() submitRequested = new EventEmitter<void>();
+ @Output() closeRequested = new EventEmitter<void>();
@ContentChildren(TearsheetStepComponent)
stepContents!: QueryList<TearsheetStepComponent>;
}
closeWideTearsheet() {
+ this.closeRequested.emit();
this.isOpen = false;
if (this.hasModalOutlet) {
this.location.back();
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: [
BlockUIModule.forRoot()
],
declarations: [FormlyTextareaTypeComponent],
- exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
+ exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule, TilesModule],
providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper, BlockUIService]
})
export class SharedModule {}
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;
}
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;
+}