]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cephfs mirroring - Entity 66857/head
authorDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Fri, 9 Jan 2026 09:59:50 +0000 (15:29 +0530)
committerDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Mon, 23 Feb 2026 07:22:34 +0000 (12:52 +0530)
Fixes: https://tracker.ceph.com/issues/74366
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
14 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-filesystem-selector/cephfs-filesystem-selector.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-wizard/cephfs-mirroring-wizard.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cluster.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/table/table.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cluster.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts

index 56d3bbb204cfcaa30b944e319fab467e72e9ca13..d22fb82c2c3901817319b418cc0b270effd946d0 100644 (file)
@@ -1,4 +1,12 @@
-import { Component, OnInit, TemplateRef, ViewChild, inject } from '@angular/core';
+import {
+  Component,
+  OnInit,
+  TemplateRef,
+  ViewChild,
+  inject,
+  Output,
+  EventEmitter
+} from '@angular/core';
 
 import { CephfsService } from '~/app/shared/api/cephfs.service';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
@@ -32,6 +40,7 @@ export class CephfsFilesystemSelectorComponent implements OnInit {
   filesystems$: Observable<FilesystemRow[]> = of([]);
   selection = new CdTableSelection();
   icons = Icons;
+  @Output() filesystemSelected = new EventEmitter<FilesystemRow | null>();
   mdsStatusLabels: Record<MdsStatus, string> = {
     Active: $localize`Active`,
     Warning: $localize`Warning`,
@@ -111,5 +120,7 @@ export class CephfsFilesystemSelectorComponent implements OnInit {
 
   updateSelection(selection: CdTableSelection) {
     this.selection = selection;
+    const selectedRow = typeof selection?.first === 'function' ? selection.first() : null;
+    this.filesystemSelected.emit(selectedRow as FilesystemRow | null);
   }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.html
new file mode 100644 (file)
index 0000000..6dd4aaf
--- /dev/null
@@ -0,0 +1,193 @@
+<div class="scroll-overflow">
+  <div
+    [cdsStack]="'vertical'"
+    gap="3">
+    <div
+      class="cds--type-heading-03"
+      i18n>Create or select entity</div>
+    <p
+      class="cds--type-body-01"
+      i18n>
+      Choose an existing CephFS mirroring entity or create a new one to be used for mirroring.
+    </p>
+  </div>
+  <cds-tile>
+    <div>
+      <cds-radio-group [(ngModel)]="isCreatingNewEntity">
+        <cds-radio
+          [value]="true"
+          (click)="onCreateEntitySelected()"
+          i18n> Create new entity </cds-radio>
+        <cds-radio
+          [value]="false"
+          (click)="onExistingEntitySelected()"
+          i18n> Use existing entity </cds-radio>
+      </cds-radio-group>
+    </div>
+
+    @if (isCreatingNewEntity) {
+    @if (showCreateRequirementsWarning) {
+    <cd-alert-panel
+      type="warning"
+      class="cds-ml-3 cds-mr-3"
+      dismissible="true"
+      (dismissed)="onDismissCreateRequirementsWarning()">
+      <div
+        [cdsStack]="'vertical'"
+        gap="2">
+        <div
+          class="cds--type-heading-compact-01"
+          i18n>Mirroring entity requirements</div>
+        <div
+          class="cds--type-body-compact-01"
+          i18n>
+          This entity will be used by the mirroring service to manage snapshots and replication.
+          It will require the proper permissions to replicate data.
+        </div>
+      </div>
+    </cd-alert-panel>
+    }
+
+    @if (showCreateCapabilitiesInfo) {
+    <cd-alert-panel
+      type="info"
+      dismissible="true"
+      (dismissed)="onDismissCreateCapabilitiesInfo()">
+      <div
+        [cdsStack]="'vertical'"
+        gap="1">
+        <div
+          class="cds--type-heading-compact-01"
+          i18n>Capabilities added automatically.</div>
+        <div
+          class="cds--type-body-compact-01 requirements-list"
+          i18n>
+          If you create a new entity, the following capabilities will be assigned automatically:
+        </div>
+        <ul class="list-disc">
+          @for (cap of capabilities; track cap.name) {
+          <li>
+            {{ cap.name }}: {{ cap.permission }}
+          </li>
+          }
+        </ul>
+      </div>
+    </cd-alert-panel>
+    }
+
+    <form
+      name="entityForm"
+      #formDir="ngForm"
+      [formGroup]="entityForm"
+      novalidate>
+      <div
+        [cdsStack]="'vertical'"
+        gap="5"
+        class="form-item form-item-append cds-mt-5">
+        <cds-text-label
+          labelInputID="user_entity"
+          i18n
+          cdRequiredField="Ceph entity"
+          [helperText]="userEntityHelperText"
+          [invalid]="!entityForm.controls['user_entity'].valid && (entityForm.controls['user_entity'].dirty)"
+          [invalidText]="userEntityError"
+          i18n-invalidText>
+          Ceph entity
+          <div
+            [cdsStack]="'horizontal'"
+            gap="0">
+            <input
+              cdsText
+              value="client."
+              readonly
+              name="client_prefix" />
+            <input
+              cdsText
+              name="user_entity"
+              formControlName="user_entity"
+              [invalid]="!entityForm.controls['user_entity'].valid && (entityForm.controls['user_entity'].dirty)" />
+          </div>
+        </cds-text-label>
+
+        <ng-template #userEntityError>
+          @if (entityForm.showError('user_entity', formDir, 'required')) {
+          <span
+            class="invalid-feedback"
+            i18n>This field is required.</span>
+          }
+          @if (entityForm.showError('user_entity', formDir, 'forbiddenClientPrefix')) {
+          <span
+            class="invalid-feedback"
+            i18n>Do not include 'client.' prefix. Enter only the entity suffix.</span>
+          }
+        </ng-template>
+      </div>
+      <div>
+        <cd-form-button-panel
+          (submitActionEvent)="submitAction()"
+          [form]="entityForm"
+          submitText="Create entity"
+          i18n-submitText
+          [showCancel]="false">
+        </cd-form-button-panel>
+      </div>
+    </form>
+    }
+
+    @if (!isCreatingNewEntity && showSelectRequirementsWarning) {
+    <cd-alert-panel
+      type="warning"
+      class="cds-ml-3 cds-mr-3"
+      dismissible="true"
+      (dismissed)="onDismissSelectRequirementsWarning()">
+      <div
+        [cdsStack]="'vertical'"
+        gap="2">
+        <div
+          class="cds--type-heading-compact-01"
+          i18n>Mirroring entity requirements</div>
+        <div
+          class="cds--type-body-compact-01"
+          i18n>
+          This selected entity will be used by the mirroring service to manage snapshots and
+          replication. It must have rwps capabilities on MDS, MON, and OSD.
+        </div>
+      </div>
+    </cd-alert-panel>
+    @if (showSelectEntityInfo) {
+    <cd-alert-panel
+      type="info"
+      class="cds-mb-3"
+      dismissible="true"
+      (dismissed)="onDismissSelectEntityInfo()">
+      <div
+        [cdsStack]="'vertical'"
+        gap="2">
+        <div
+          class="cds--type-heading-01"
+          i18n>Entity selection note</div>
+        <div
+          class="cds--type-body-compact-01"
+          i18n>
+          Only entities with valid rwps capabilities can be used for mirroring.
+        </div>
+      </div>
+    </cd-alert-panel>
+    }
+    }
+
+    @let entities = (entities$ | async);
+    @if (!isCreatingNewEntity && entities) {
+    <cd-table
+      #table
+      [data]="entities"
+      [columns]="columns"
+      columnMode="flex"
+      selectionType="singleRadio"
+      (updateSelection)="updateSelection($event)"
+      (fetchData)="loadEntities($event)">
+    </cd-table>
+    }
+  </cds-tile>
+</div>
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.scss
new file mode 100644 (file)
index 0000000..f359d81
--- /dev/null
@@ -0,0 +1,17 @@
+@use '@carbon/layout';
+
+.scroll-overflow {
+  max-height: 70vh;
+  overflow-y: auto;
+  overflow-x: hidden;
+}
+
+.requirements-list {
+  list-style-type: disc;
+  padding-left: var(--cds-spacing-05);
+  margin-left: layout.$spacing-02;
+}
+
+.list-disc {
+  list-style-type: disc;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.spec.ts
new file mode 100644 (file)
index 0000000..893cfcf
--- /dev/null
@@ -0,0 +1,185 @@
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { of } from 'rxjs';
+
+import { CephfsMirroringEntityComponent } from './cephfs-mirroring-entity.component';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ClusterService } from '~/app/shared/api/cluster.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+
+describe('CephfsMirroringEntityComponent', () => {
+  let component: CephfsMirroringEntityComponent;
+  let fixture: ComponentFixture<CephfsMirroringEntityComponent>;
+
+  let clusterServiceMock: any;
+  let cephfsServiceMock: any;
+  let taskWrapperServiceMock: any;
+
+  beforeEach(async () => {
+    clusterServiceMock = {
+      listUser: jest.fn(),
+      createUser: jest.fn()
+    };
+
+    cephfsServiceMock = {
+      setAuth: jest.fn()
+    };
+
+    taskWrapperServiceMock = {
+      wrapTaskAroundCall: jest.fn()
+    };
+
+    await TestBed.configureTestingModule({
+      declarations: [CephfsMirroringEntityComponent],
+      imports: [ReactiveFormsModule],
+      providers: [
+        CdFormBuilder,
+        { provide: ClusterService, useValue: clusterServiceMock },
+        { provide: CephfsService, useValue: cephfsServiceMock },
+        { provide: TaskWrapperService, useValue: taskWrapperServiceMock }
+      ]
+    })
+      .overrideComponent(CephfsMirroringEntityComponent, {
+        set: { template: '' }
+      })
+      .compileComponents();
+
+    fixture = TestBed.createComponent(CephfsMirroringEntityComponent);
+    component = fixture.componentInstance;
+
+    component.selectedFilesystem = { name: 'myfs' } as any;
+
+    clusterServiceMock.listUser.mockReturnValue(of([]));
+
+    fixture.detectChanges();
+  });
+
+  afterEach(() => {
+    jest.clearAllMocks();
+  });
+
+  it('should create component', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should initialize form with required + noClientPrefix validator', () => {
+    const control = component.entityForm.get('user_entity');
+
+    control?.setValue('');
+    expect(control?.valid).toBeFalsy();
+
+    control?.setValue('client.test');
+    expect(control?.errors?.['forbiddenClientPrefix']).toBeTruthy();
+
+    control?.setValue('validuser');
+    expect(control?.valid).toBeTruthy();
+  });
+
+  it('should filter entities based on fsname', fakeAsync(() => {
+    clusterServiceMock.listUser.mockReturnValue(
+      of([
+        {
+          entity: 'client.valid',
+          caps: { mds: 'allow rw fsname=myfs' }
+        },
+        {
+          entity: 'client.invalid',
+          caps: { mds: 'allow rw fsname=otherfs' }
+        },
+        {
+          entity: 'osd.something',
+          caps: { mds: 'allow rw fsname=myfs' }
+        }
+      ])
+    );
+
+    component.loadEntities();
+    tick();
+
+    component.entities$.subscribe((rows) => {
+      expect(rows.length).toBe(1);
+      expect(rows[0].entity).toBe('client.valid');
+      expect(rows[0].mdsCaps).toContain('fsname=myfs');
+    });
+  }));
+
+  it('should not submit if form invalid', () => {
+    component.entityForm.get('user_entity')?.setValue('');
+
+    component.submitAction();
+
+    expect(clusterServiceMock.createUser).not.toHaveBeenCalled();
+    expect(component.isSubmitting).toBeFalsy();
+  });
+
+  it('should create entity and set auth successfully', fakeAsync(() => {
+    component.entityForm.get('user_entity')?.setValue('newuser');
+
+    clusterServiceMock.createUser.mockReturnValue(of({}));
+    taskWrapperServiceMock.wrapTaskAroundCall.mockReturnValue(of({}));
+    cephfsServiceMock.setAuth.mockReturnValue(of({}));
+
+    const emitSpy = jest.spyOn(component.entitySelected, 'emit');
+
+    component.submitAction();
+    tick();
+
+    expect(clusterServiceMock.createUser).toHaveBeenCalledWith(
+      expect.objectContaining({
+        user_entity: 'client.newuser'
+      })
+    );
+
+    expect(cephfsServiceMock.setAuth).toHaveBeenCalledWith('myfs', 'newuser', ['/', 'rwps'], false);
+
+    expect(emitSpy).toHaveBeenCalledWith('client.newuser');
+    expect(component.isSubmitting).toBeFalsy();
+    expect(component.isCreatingNewEntity).toBeFalsy();
+  }));
+
+  it('should emit selected entity on updateSelection', () => {
+    const selection = new CdTableSelection();
+    selection.selected = [{ entity: 'client.test' }];
+
+    const emitSpy = jest.spyOn(component.entitySelected, 'emit');
+
+    component.updateSelection(selection);
+
+    expect(emitSpy).toHaveBeenCalledWith('client.test');
+  });
+
+  it('should emit null when selection empty', () => {
+    const selection = new CdTableSelection();
+    selection.selected = [];
+
+    const emitSpy = jest.spyOn(component.entitySelected, 'emit');
+
+    component.updateSelection(selection);
+
+    expect(emitSpy).toHaveBeenCalledWith(null);
+  });
+
+  it('should toggle create/existing states correctly', () => {
+    component.onExistingEntitySelected();
+    expect(component.isCreatingNewEntity).toBeFalsy();
+
+    component.onCreateEntitySelected();
+    expect(component.isCreatingNewEntity).toBeTruthy();
+  });
+
+  it('should dismiss warnings correctly', () => {
+    component.onDismissCreateRequirementsWarning();
+    expect(component.showCreateRequirementsWarning).toBeFalsy();
+
+    component.onDismissCreateCapabilitiesInfo();
+    expect(component.showCreateCapabilitiesInfo).toBeFalsy();
+
+    component.onDismissSelectRequirementsWarning();
+    expect(component.showSelectRequirementsWarning).toBeFalsy();
+
+    component.onDismissSelectEntityInfo();
+    expect(component.showSelectEntityInfo).toBeFalsy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-entity/cephfs-mirroring-entity.component.ts
new file mode 100644 (file)
index 0000000..1fad704
--- /dev/null
@@ -0,0 +1,233 @@
+import { Component, OnInit, Output, EventEmitter, Input, inject } from '@angular/core';
+import { BehaviorSubject, Observable, of } from 'rxjs';
+import { catchError, map, switchMap, defaultIfEmpty } from 'rxjs/operators';
+
+import { CdTableColumn } from '~/app/shared/models/cd-table-column';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { ClusterService } from '~/app/shared/api/cluster.service';
+
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { Validators, AbstractControl, ValidationErrors, ValidatorFn } from '@angular/forms';
+import { CdForm } from '~/app/shared/forms/cd-form';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { FinishedTask } from '~/app/shared/models/finished-task';
+import { FilesystemRow, MirroringEntityRow } from '~/app/shared/models/cephfs.model';
+import { CephAuthUser } from '~/app/shared/models/cluster.model';
+
+@Component({
+  selector: 'cd-cephfs-mirroring-entity',
+  templateUrl: './cephfs-mirroring-entity.component.html',
+  styleUrls: ['./cephfs-mirroring-entity.component.scss'],
+  standalone: false
+})
+export class CephfsMirroringEntityComponent extends CdForm implements OnInit {
+  columns: CdTableColumn[];
+  selection = new CdTableSelection();
+
+  subject$ = new BehaviorSubject<void>(undefined);
+  entities$: Observable<MirroringEntityRow[]>;
+  context: CdTableFetchDataContext;
+  capabilities = [
+    { name: $localize`MDS`, permission: 'rwps' },
+    { name: $localize`MON`, permission: 'rwps' },
+    { name: $localize`OSD`, permission: 'rwps' }
+  ];
+
+  isCreatingNewEntity = true;
+  showCreateRequirementsWarning = true;
+  showCreateCapabilitiesInfo = true;
+  showSelectRequirementsWarning = true;
+  showSelectEntityInfo = true;
+
+  entityForm: CdFormGroup;
+
+  readonly userEntityHelperText = $localize`Ceph Authentication entity used by mirroring.`;
+
+  @Input() selectedFilesystem: FilesystemRow | null = null;
+  @Output() entitySelected = new EventEmitter<string | null>();
+  isSubmitting: boolean = false;
+
+  private cephfsService = inject(CephfsService);
+  private clusterService = inject(ClusterService);
+  private taskWrapperService = inject(TaskWrapperService);
+  private formBuilder = inject(CdFormBuilder);
+
+  ngOnInit(): void {
+    const noClientPrefix: ValidatorFn = (control: AbstractControl): ValidationErrors | null => {
+      const value = (control.value ?? '').toString().trim();
+      if (!value) return null;
+      return value.startsWith('client.') ? { forbiddenClientPrefix: true } : null;
+    };
+
+    this.entityForm = this.formBuilder.group({
+      user_entity: ['', [Validators.required, noClientPrefix]]
+    });
+
+    this.columns = [
+      {
+        name: $localize`Entity ID`,
+        prop: 'entity',
+        flexGrow: 2
+      },
+      {
+        name: $localize`MDS capabilities`,
+        prop: 'mdsCaps',
+        flexGrow: 1.5
+      },
+      {
+        name: $localize`MON capabilities`,
+        prop: 'monCaps',
+        flexGrow: 1.5
+      },
+      {
+        name: $localize`OSD capabilities`,
+        prop: 'osdCaps',
+        flexGrow: 1.5
+      }
+    ];
+
+    this.entities$ = this.subject$.pipe(
+      switchMap(() =>
+        this.clusterService.listUser().pipe(
+          switchMap((users) => {
+            const typedUsers = (users as CephAuthUser[]) || [];
+            const filteredEntities = typedUsers.filter((entity) => {
+              if (entity.entity?.startsWith('client.')) {
+                const caps = entity.caps || {};
+                const mdsCaps = caps.mds || '-';
+
+                const fsName = this.selectedFilesystem?.name || '';
+                const isValid = mdsCaps.includes(`fsname=${fsName}`);
+
+                return isValid;
+              }
+              return false;
+            });
+
+            const rows: MirroringEntityRow[] = filteredEntities.map((entity) => {
+              const caps = entity.caps || {};
+              const mdsCaps = caps.mds || '-';
+              const monCaps = caps.mon || '-';
+              const osdCaps = caps.osd || '-';
+
+              return {
+                entity: entity.entity,
+                mdsCaps,
+                monCaps,
+                osdCaps
+              };
+            });
+
+            return of(rows);
+          }),
+          catchError(() => {
+            this.context?.error();
+            return of([]);
+          })
+        )
+      )
+    );
+
+    this.loadEntities();
+  }
+
+  submitAction(): void {
+    if (!this.entityForm.valid) {
+      this.entityForm.markAllAsTouched();
+      return;
+    }
+
+    const clientEntity = (this.entityForm.get('user_entity')?.value || '').toString().trim();
+    const fullEntity = `client.${clientEntity}`;
+    const fsName = this.selectedFilesystem?.name;
+
+    const payload = {
+      user_entity: fullEntity,
+      capabilities: [
+        { entity: 'mds', cap: 'allow *' },
+        { entity: 'mgr', cap: 'allow *' },
+        { entity: 'mon', cap: 'allow *' },
+        { entity: 'osd', cap: 'allow *' }
+      ]
+    };
+
+    this.isSubmitting = true;
+
+    this.taskWrapperService
+      .wrapTaskAroundCall({
+        task: new FinishedTask(`ceph-user/create`, {
+          userEntity: fullEntity,
+          fsName: fsName
+        }),
+        call: this.clusterService.createUser(payload).pipe(
+          map((res) => {
+            return { ...(res as Record<string, unknown>), __taskCompleted: true };
+          })
+        )
+      })
+      .pipe(
+        defaultIfEmpty(null),
+        switchMap(() => {
+          if (fsName) {
+            return this.cephfsService.setAuth(fsName, clientEntity, ['/', 'rwps'], false);
+          }
+          return of(null);
+        })
+      )
+      .subscribe({
+        complete: () => {
+          this.isSubmitting = false;
+          this.entityForm.reset();
+          this.handleEntityCreated(fullEntity);
+        }
+      });
+  }
+
+  private handleEntityCreated(entityId: string) {
+    this.loadEntities(this.context);
+    this.entitySelected.emit(entityId);
+    this.isCreatingNewEntity = false;
+  }
+
+  loadEntities(context?: CdTableFetchDataContext) {
+    this.context = context;
+    this.subject$.next();
+  }
+
+  updateSelection(selection: CdTableSelection) {
+    this.selection = selection;
+    const selectedRow = selection?.first();
+    this.entitySelected.emit(selectedRow ? selectedRow.entity : null);
+  }
+
+  onCreateEntitySelected() {
+    this.isCreatingNewEntity = true;
+    this.showCreateRequirementsWarning = true;
+    this.showCreateCapabilitiesInfo = true;
+  }
+
+  onExistingEntitySelected() {
+    this.isCreatingNewEntity = false;
+    this.showSelectRequirementsWarning = true;
+    this.showSelectEntityInfo = true;
+  }
+
+  onDismissCreateRequirementsWarning() {
+    this.showCreateRequirementsWarning = false;
+  }
+
+  onDismissCreateCapabilitiesInfo() {
+    this.showCreateCapabilitiesInfo = false;
+  }
+
+  onDismissSelectRequirementsWarning() {
+    this.showSelectRequirementsWarning = false;
+  }
+
+  onDismissSelectEntityInfo() {
+    this.showSelectEntityInfo = false;
+  }
+}
index 029bcc3f294047ecffd025b4482160bd386fba10..5c0f99ec8a18883eeed9568468b8a052888d4634 100644 (file)
@@ -9,7 +9,7 @@
       <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>
+        <p i18n>Select how the cluster will participate in the CephFS mirroring relationship.</p>
       </div>
 
       <div cdsStack="horizontal">
         dismissible="true"
         (dismissed)="showMessage = false">
         <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>
+          <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>
+          <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>
 
   <!-- Step 1 -->
   <cd-tearsheet-step>
-    <cd-cephfs-filesystem-selector>
+    <cd-cephfs-filesystem-selector (filesystemSelected)="onFilesystemSelected($event)">
     </cd-cephfs-filesystem-selector>
   </cd-tearsheet-step>
 
   <!-- Step 2 -->
   <cd-tearsheet-step>
-    <div>Test 2</div>
+    <cd-cephfs-mirroring-entity [selectedFilesystem]="selectedFilesystem"
+                                (entitySelected)="onEntitySelected($event)">
+    </cd-cephfs-mirroring-entity>
   </cd-tearsheet-step>
 
   <!-- Step 3 -->
   <cd-tearsheet-step>
     <div>
-      <p>Test3</p>
+      Test 3
     </div>
   </cd-tearsheet-step>
 </cd-tearsheet>
index c24710113ea6c79ee9718d2256c0545102907214..75554600392d470ed64b3b15fcecc138b5cc52d9 100644 (file)
@@ -9,6 +9,7 @@ import {
 import { WizardStepsService } from '~/app/shared/services/wizard-steps.service';
 import { WizardStepModel } from '~/app/shared/models/wizard-steps';
 import { FormBuilder, FormGroup } from '@angular/forms';
+import { FilesystemRow } from '~/app/shared/models/cephfs.model';
 @Component({
   selector: 'cd-cephfs-mirroring-wizard',
   templateUrl: './cephfs-mirroring-wizard.component.html',
@@ -21,6 +22,8 @@ export class CephfsMirroringWizardComponent implements OnInit {
   description: string = $localize`Configure a new mirroring relationship between clusters`;
   form: FormGroup;
   showMessage: boolean = true;
+  selectedFilesystem: FilesystemRow | null = null;
+  selectedEntity: string | null = null;
 
   LOCAL_ROLE = LOCAL_ROLE;
   REMOTE_ROLE = REMOTE_ROLE;
@@ -54,10 +57,25 @@ export class CephfsMirroringWizardComponent implements OnInit {
     const stepsData = this.wizardStepsService.steps$.value;
     this.steps = STEP_TITLES_MIRRORING_CONFIGURED.map((title, index) => ({
       label: title,
-      onClick: () => this.goToStep(stepsData[index])
+      onClick: () => this.goToStep(stepsData[index]),
+      invalid: true
     }));
   }
 
+  onFilesystemSelected(filesystem: FilesystemRow) {
+    this.selectedFilesystem = filesystem;
+    if (this.steps[1]) {
+      this.steps[1].invalid = !filesystem;
+    }
+  }
+
+  onEntitySelected(entity: string) {
+    this.selectedEntity = entity;
+    if (this.steps[2]) {
+      this.steps[2].invalid = !entity;
+    }
+  }
+
   goToStep(step: WizardStepModel) {
     if (step) {
       this.wizardStepsService.setCurrentStep(step);
@@ -67,11 +85,17 @@ export class CephfsMirroringWizardComponent implements OnInit {
   onLocalRoleChange() {
     this.form.patchValue({ localRole: LOCAL_ROLE, remoteRole: null });
     this.showMessage = false;
+    if (this.steps[0]) {
+      this.steps[0].invalid = false;
+    }
   }
 
   onRemoteRoleChange() {
     this.form.patchValue({ localRole: null, remoteRole: REMOTE_ROLE });
     this.showMessage = true;
+    if (this.steps[0]) {
+      this.steps[0].invalid = false;
+    }
   }
 
   onSubmit() {}
index b6461be67ff6e0ea8f6cbcdde648606042925a12..75a2d19746d75b01ee14585ab608168ddf427402 100644 (file)
@@ -47,12 +47,12 @@ import {
   ModalModule,
   NumberModule,
   PlaceholderModule,
+  RadioModule,
   SelectModule,
   TimePickerModule,
   TilesModule,
   TreeviewModule,
   TabsModule,
-  RadioModule,
   NotificationModule
 } from 'carbon-components-angular';
 
@@ -62,6 +62,7 @@ import Close from '@carbon/icons/es/close/32';
 import Trash from '@carbon/icons/es/trash-can/32';
 import { CephfsMirroringWizardComponent } from './cephfs-mirroring-wizard/cephfs-mirroring-wizard.component';
 import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/cephfs-filesystem-selector.component';
+import { CephfsMirroringEntityComponent } from './cephfs-mirroring-entity/cephfs-mirroring-entity.component';
 
 @NgModule({
   imports: [
@@ -92,6 +93,7 @@ import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/
     LayoutModule,
     ComboBoxModule,
     IconModule,
+    RadioModule,
     BaseChartDirective,
     TabsModule,
     RadioModule,
@@ -120,7 +122,8 @@ import { CephfsFilesystemSelectorComponent } from './cephfs-filesystem-selector/
     CephfsMirroringListComponent,
     CephfsMirroringWizardComponent,
     CephfsFilesystemSelectorComponent,
-    CephfsMirroringErrorComponent
+    CephfsMirroringErrorComponent,
+    CephfsMirroringEntityComponent
   ],
   providers: [provideCharts(withDefaultRegisterables())]
 })
index bbd263b66e3dc6670a0dadcafeddda518c93bfdc..2d47aceed95efcfc8d79c33eda73cf3d637abccd 100644 (file)
@@ -115,7 +115,7 @@ export class CephfsService {
     });
   }
 
-  setAuth(fsName: string, clientId: number, caps: string[], rootSquash: boolean) {
+  setAuth(fsName: string, clientId: string | number, caps: string[], rootSquash: boolean) {
     return this.http.put(`${this.baseURL}/auth`, {
       fs_name: fsName,
       client_id: `client.${clientId}`,
index 6b435d6ffed1dc15a7c5a4de19e9a2d650a7acfe..7223ac8e45653f1f7b6e098e60529e342e6897e0 100644 (file)
@@ -3,6 +3,8 @@ import { Injectable } from '@angular/core';
 
 import { Observable } from 'rxjs';
 
+import { CephClusterUser } from '~/app/shared/models/cluster.model';
+
 @Injectable({
   providedIn: 'root'
 })
@@ -24,4 +26,12 @@ export class ClusterService {
       { headers: { Accept: 'application/vnd.ceph.api.v0.1+json' } }
     );
   }
+
+  listUser(): Observable<CephClusterUser[]> {
+    return this.http.get<CephClusterUser[]>(`${this.baseURL}/user`);
+  }
+
+  createUser(payload: CephClusterUser) {
+    return this.http.post(`${this.baseURL}/user`, payload);
+  }
 }
index 2ce7a5182d4374e3d180f8149eb52b75bfafc3e3..081c2433fb1a25b7e3ede5d60eb6a2b577b87f27 100644 (file)
@@ -1147,6 +1147,8 @@ export class TableComponent implements AfterViewInit, OnInit, OnChanges, OnDestr
   onDeselect(deselectedRowIndex: number) {
     this.model.selectRow(deselectedRowIndex, false);
     if (this.selectionType === 'single' || this.selectionType === 'singleRadio') {
+      this.selection.selected = [];
+      this.updateSelection.emit(this.selection);
       return;
     }
     this._toggleSelection(deselectedRowIndex, false);
index e6db422200091c0845da2d38fecf48705c2510d6..4f3d4450ce5ff8ab60ea9018464783c8978e4f1c 100644 (file)
@@ -130,3 +130,10 @@ export function mdsStateToStatus(state: string | undefined): MdsStatus {
 }
 
 export type DaemonResponse = Daemon[];
+
+export type MirroringEntityRow = {
+  entity: string;
+  mdsCaps: string;
+  monCaps: string;
+  osdCaps: string;
+};
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cluster.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cluster.model.ts
new file mode 100644 (file)
index 0000000..fc1aece
--- /dev/null
@@ -0,0 +1,13 @@
+export interface CephClusterUser {
+  [key: string]: unknown;
+}
+
+export type CephAuthUser = {
+  [key: string]: unknown;
+  entity?: string;
+  caps?: {
+    mds?: string;
+    mon?: string;
+    osd?: string;
+  };
+};
index 48cc978d279b39f0c3cd43f8a80bfa9a53aeb0b1..001daa9829ec02282686e98a230513d80f14a1bd 100644 (file)
@@ -551,6 +551,10 @@ export class TaskMessageService {
     'cephfs/smb/standalone/delete': this.newTaskMessage(
       this.commonOperations.delete,
       (metadata: { usersGroupsId: string }) => this.smbUsersgroups(metadata)
+    ),
+    'ceph-user/create': this.newTaskMessage(
+      this.commonOperations.create,
+      (metadata: { userEntity: string }) => this.cephUser(metadata)
     )
   };
 
@@ -726,4 +730,8 @@ export class TaskMessageService {
   getRunningText(task: Task) {
     return this._getTaskTitle(task).operation.running;
   }
+
+  cephUser(metadata: { userEntity: string }) {
+    return $localize`Ceph user  '${metadata.userEntity}'`;
+  }
 }