]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cephfs Mirroring Step 1 - Path Selection
authorDnyaneshwari Talwekar <dtalwekar@li-4c4c4544-0038-3510-8056-b5c04f473234.ibm.com>
Fri, 26 Jun 2026 08:07:20 +0000 (13:37 +0530)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 05:31:41 +0000 (07:31 +0200)
Fixes: https://tracker.ceph.com/issues/77727
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
21 files changed:
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/tests/test_cephfs.py

index 5d30c1cd7abb4f84b93c1ed1b271d334f784fac7..f2157381e4d52f9c1b5f2a26f352c548a8ca206f 100644 (file)
@@ -1395,6 +1395,41 @@ class CephFSMirror(RESTController):
                 component='cephfs.mirror'
             )
 
+    @EndpointDoc("Add a directory path for snapshot mirroring",
+                 parameters={
+                     'fs_name': (str, 'File system name'),
+                     'path': (str, 'Directory path to mirror'),
+                 })
+    @Endpoint('POST', path='/directory')
+    @CreatePermission
+    def add_directory(self, fs_name: str, path: str):
+        error_code, out, err = mgr.remote(
+            'mirroring', 'snapshot_mirror_add_dir', fs_name, path)
+        if error_code != 0:
+            raise DashboardException(
+                msg=f'Failed to add mirroring path {path}: {err}',
+                code=error_code,
+                component='cephfs.mirror'
+            )
+        return json.loads(out) if out else {}
+
+    @EndpointDoc("List snapshot mirrored directories",
+                 parameters={
+                     'fs_name': (str, 'File system name'),
+                 })
+    @Endpoint('GET', path='/directory')
+    @ReadPermission
+    def list_directories(self, fs_name: str):
+        error_code, out, err = mgr.remote(
+            'mirroring', 'snapshot_mirror_ls', fs_name)
+        if error_code != 0:
+            raise DashboardException(
+                msg=f'Failed to list mirroring directories: {err}',
+                code=error_code,
+                component='cephfs.mirror'
+            )
+        return json.loads(out) if out else []
+
     @EndpointDoc("Get mirror daemon and peers information",
                  responses={200: DAEMON_STATUS_SCHEMA})
     @Endpoint('GET', path='/daemon-status')
index ce4f53d7288294b0c3b957112f7d424c7a977ba8..5e6c207fdc69aba5ca85574d5cd271089e72abc8 100644 (file)
   i18n-submitButtonLabel
   (submitRequested)="onSubmit()"
   (closeRequested)="onCancel()">
-  <cd-tearsheet-step></cd-tearsheet-step>
+  <cd-tearsheet-step>
+    <cd-mirroring-paths-step
+      #pathsStep
+      #tearsheetStep
+      [fsName]="fsName"
+      [fsId]="fsId">
+    </cd-mirroring-paths-step>
+  </cd-tearsheet-step>
   <cd-tearsheet-step></cd-tearsheet-step>
   <cd-tearsheet-step></cd-tearsheet-step>
 </cd-tearsheet>
index a0f4a463a830781d8de26260095036484ff7a41f..b1d0ae45c3728af0524d0c3e10e7f617848e6b9b 100644 (file)
@@ -1,15 +1,29 @@
 import { NO_ERRORS_SCHEMA } from '@angular/core';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, fakeAsync, flush, TestBed, tick } from '@angular/core/testing';
 import { ActivatedRoute, convertToParamMap, Router } from '@angular/router';
+import { asyncScheduler, defer, of, throwError } from 'rxjs';
+import { observeOn } from 'rxjs/operators';
 
 import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path.component';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
 
 describe('CephfsAddMirroringPathComponent', () => {
   let component: CephfsAddMirroringPathComponent;
   let fixture: ComponentFixture<CephfsAddMirroringPathComponent>;
   let routerNavigateSpy: jest.Mock;
 
+  const cephfsServiceMock = {
+    addMirrorDirectory: jest.fn()
+  };
+
+  const notificationServiceMock = {
+    show: jest.fn()
+  };
+
   beforeEach(async () => {
+    jest.clearAllMocks();
     routerNavigateSpy = jest.fn();
 
     await TestBed.configureTestingModule({
@@ -26,7 +40,9 @@ describe('CephfsAddMirroringPathComponent', () => {
         {
           provide: Router,
           useValue: { navigate: routerNavigateSpy }
-        }
+        },
+        { provide: CephfsService, useValue: cephfsServiceMock },
+        { provide: NotificationService, useValue: notificationServiceMock }
       ],
       schemas: [NO_ERRORS_SCHEMA]
     })
@@ -57,15 +73,138 @@ describe('CephfsAddMirroringPathComponent', () => {
     expect(component.steps.map((step) => step.label)).toEqual(['Paths', 'Schedule', 'Review']);
   });
 
-  it('should close modal outlet on submit', () => {
+  it('should skip API calls when the paths step form is invalid', () => {
+    const refreshTrackedPaths = jest.fn();
+    component.pathsStep = {
+      formGroup: {
+        markAllAsTouched: jest.fn(),
+        updateValueAndValidity: jest.fn(),
+        invalid: true
+      },
+      refreshTrackedPaths,
+      getSubmitPaths: () => ({ toAdd: [], alreadyMirrored: [] })
+    } as any;
+
+    component.onSubmit();
+
+    expect(component.pathsStep.formGroup.markAllAsTouched).toHaveBeenCalled();
+    expect(refreshTrackedPaths).not.toHaveBeenCalled();
+    expect(notificationServiceMock.show).not.toHaveBeenCalled();
+    expect(cephfsServiceMock.addMirrorDirectory).not.toHaveBeenCalled();
+    expect(component.isSubmitLoading).toBe(false);
+    expect(routerNavigateSpy).not.toHaveBeenCalled();
+  });
+
+  function mockValidPathsStep(overrides: Record<string, unknown> = {}): void {
+    component.pathsStep = {
+      formGroup: {
+        markAllAsTouched: jest.fn(),
+        updateValueAndValidity: jest.fn(),
+        invalid: false
+      },
+      refreshTrackedPaths: () => of(undefined),
+      getSubmitPaths: () => ({ toAdd: [], alreadyMirrored: [] }),
+      addTrackedPath: jest.fn(),
+      ...overrides
+    } as any;
+  }
+
+  it('should add mirror directories and close modal on success', fakeAsync(() => {
+    component.ngOnInit();
+    mockValidPathsStep({
+      getSubmitPaths: () => ({ toAdd: ['/volumes/g1/sv1', '/volumes/g1/sv2'], alreadyMirrored: [] })
+    });
+
+    cephfsServiceMock.addMirrorDirectory.mockImplementation((_fs: string, path: string) =>
+      defer(() => of({ path }).pipe(observeOn(asyncScheduler)))
+    );
+
+    component.onSubmit();
+    tick();
+    flush();
+
+    expect(cephfsServiceMock.addMirrorDirectory).toHaveBeenCalledTimes(2);
+    expect(cephfsServiceMock.addMirrorDirectory).toHaveBeenCalledWith('testfs', '/volumes/g1/sv1');
+    expect(cephfsServiceMock.addMirrorDirectory).toHaveBeenCalledWith('testfs', '/volumes/g1/sv2');
+    expect(
+      notificationServiceMock.show.mock.calls.filter(([type]) => type === NotificationType.success)
+        .length
+    ).toBe(1);
+    expect(notificationServiceMock.show).toHaveBeenCalledWith(
+      NotificationType.success,
+      expect.stringContaining('2'),
+      expect.stringMatching(/\/volumes\/g1\/sv1[\s\S]*\/volumes\/g1\/sv2/)
+    );
+    expect(routerNavigateSpy).toHaveBeenCalledWith(
+      ['/cephfs/mirroring', { outlets: { modal: null } }],
+      { state: { reload: true } }
+    );
+  }));
+
+  it('should show a single aggregated error notification when paths fail', fakeAsync(() => {
     component.ngOnInit();
+    mockValidPathsStep({
+      getSubmitPaths: () => ({
+        toAdd: ['/volumes/g1/sv1', '/volumes/g1/sv2'],
+        alreadyMirrored: []
+      })
+    });
+
+    cephfsServiceMock.addMirrorDirectory.mockImplementation((_fs: string, path: string) =>
+      throwError(() => ({
+        error: { detail: `failed for ${path}` }
+      }))
+    );
+
     component.onSubmit();
+    tick();
+    flush();
+
+    expect(cephfsServiceMock.addMirrorDirectory).toHaveBeenCalledTimes(2);
+    expect(
+      notificationServiceMock.show.mock.calls.filter(([type]) => type === NotificationType.error)
+        .length
+    ).toBe(1);
+    expect(notificationServiceMock.show).toHaveBeenCalledWith(
+      NotificationType.error,
+      expect.stringContaining('2'),
+      expect.stringContaining('/volumes/g1/sv1')
+    );
+    expect(routerNavigateSpy).not.toHaveBeenCalled();
+  }));
 
+  it('should show aggregated success and error notifications for partial failures', fakeAsync(() => {
+    component.ngOnInit();
+    mockValidPathsStep({
+      getSubmitPaths: () => ({
+        toAdd: ['/volumes/g1/sv1', '/volumes/g1/sv2'],
+        alreadyMirrored: []
+      })
+    });
+
+    cephfsServiceMock.addMirrorDirectory.mockImplementation((_fs: string, path: string) =>
+      path === '/volumes/g1/sv1'
+        ? of({ path })
+        : throwError(() => ({ error: { detail: `failed for ${path}` } }))
+    );
+
+    component.onSubmit();
+    tick();
+    flush();
+
+    expect(
+      notificationServiceMock.show.mock.calls.filter(([type]) => type === NotificationType.success)
+        .length
+    ).toBe(1);
+    expect(
+      notificationServiceMock.show.mock.calls.filter(([type]) => type === NotificationType.error)
+        .length
+    ).toBe(1);
     expect(routerNavigateSpy).toHaveBeenCalledWith(
       ['/cephfs/mirroring', { outlets: { modal: null } }],
       { state: { reload: true } }
     );
-  });
+  }));
 
   it('should close modal outlet on cancel without reload', () => {
     component.ngOnInit();
@@ -93,7 +232,9 @@ describe('CephfsAddMirroringPathComponent', () => {
         {
           provide: Router,
           useValue: { navigate: routerNavigateSpy }
-        }
+        },
+        { provide: CephfsService, useValue: cephfsServiceMock },
+        { provide: NotificationService, useValue: notificationServiceMock }
       ],
       schemas: [NO_ERRORS_SCHEMA]
     })
index 341279e3d59b30e9cabfc0ba7f35a808569d4fe0..e1b99e46bf23ccd594714e24ad3513d77afa6970 100644 (file)
@@ -1,6 +1,16 @@
-import { Component, inject, OnInit } from '@angular/core';
+import { Component, DestroyRef, inject, OnInit, ViewChild } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
 import { ActivatedRoute, Router } from '@angular/router';
 import { Step } from 'carbon-components-angular';
+import { from, of } from 'rxjs';
+import { catchError, concatMap, finalize, map, switchMap, tap, toArray } from 'rxjs/operators';
+
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { MirroringPathUtils } from './mirroring-path-utils';
+import { PathSubmitFailure, PathSubmitOutput } from './mirroring-path.model';
+import { MirroringPathsStepComponent } from './mirroring-paths-step/mirroring-paths-step.component';
 
 import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant';
 
@@ -11,6 +21,14 @@ import { CEPHFS_MIRRORING_URL } from '~/app/shared/constants/cephfs.constant';
   standalone: false
 })
 export class CephfsAddMirroringPathComponent implements OnInit {
+  @ViewChild('pathsStep') pathsStep!: MirroringPathsStepComponent;
+
+  private route = inject(ActivatedRoute);
+  private router = inject(Router);
+  private cephfsService = inject(CephfsService);
+  private notificationService = inject(NotificationService);
+  private destroyRef = inject(DestroyRef);
+
   fsName = '';
   fsId = 0;
   modalHeaderLabel = $localize`Filesystem mirroring`;
@@ -22,9 +40,6 @@ export class CephfsAddMirroringPathComponent implements OnInit {
   ];
   isSubmitLoading = false;
 
-  private route = inject(ActivatedRoute);
-  private router = inject(Router);
-
   ngOnInit(): void {
     this.fsId = Number(this.route.snapshot.paramMap.get('fsId'));
     const fsName = this.route.snapshot.paramMap.get('fsName') ?? '';
@@ -36,7 +51,86 @@ export class CephfsAddMirroringPathComponent implements OnInit {
   }
 
   onSubmit(): void {
-    this.closeTearsheet(true);
+    const pathsStep = this.pathsStep;
+    if (!pathsStep?.formGroup) {
+      return;
+    }
+
+    pathsStep.formGroup.markAllAsTouched();
+    pathsStep.formGroup.updateValueAndValidity();
+    if (pathsStep.formGroup.invalid) {
+      return;
+    }
+
+    this.isSubmitLoading = true;
+
+    pathsStep
+      .refreshTrackedPaths()
+      .pipe(
+        switchMap(() => {
+          const { toAdd, alreadyMirrored } = pathsStep.getSubmitPaths();
+
+          if (!toAdd.length) {
+            this.showSubmitSummary({
+              failed: [],
+              alreadyMirrored,
+              skippedByServer: [],
+              succeeded: []
+            });
+            return of([] as (string | null)[]);
+          }
+
+          const skippedByServer: string[] = [];
+          const failed: PathSubmitFailure[] = [];
+
+          return from(toAdd).pipe(
+            concatMap((path) => {
+              if (!pathsStep.getSubmitPaths().toAdd.includes(path)) {
+                skippedByServer.push(path);
+                return of(null);
+              }
+
+              return this.cephfsService.addMirrorDirectory(this.fsName, path).pipe(
+                tap(() => pathsStep.addTrackedPath(path)),
+                map(() => path),
+                catchError((error) => {
+                  const detail =
+                    error?.error?.detail ||
+                    error?.message ||
+                    $localize`Failed to add mirroring path '${path}'`;
+                  if (MirroringPathUtils.isAlreadyTrackedMirrorError(detail)) {
+                    pathsStep.addTrackedPath(path);
+                    skippedByServer.push(path);
+                    return of(null);
+                  }
+                  failed.push({ path, detail });
+                  return of(null);
+                })
+              );
+            }),
+            toArray(),
+            tap((results) => {
+              const succeeded = results.filter((path): path is string => !!path);
+              this.showSubmitSummary({
+                failed,
+                alreadyMirrored,
+                skippedByServer,
+                succeeded
+              });
+            })
+          );
+        }),
+        finalize(() => {
+          this.isSubmitLoading = false;
+        }),
+        takeUntilDestroyed(this.destroyRef)
+      )
+      .subscribe((results) => {
+        const succeeded = results.filter((path): path is string => !!path);
+        if (succeeded.length) {
+          this.closeTearsheet(true);
+        }
+      });
   }
 
   onCancel(): void {
@@ -48,4 +142,49 @@ export class CephfsAddMirroringPathComponent implements OnInit {
       state: reload ? { reload: true } : undefined
     });
   }
+
+  private showSubmitSummary(outcome: PathSubmitOutput): void {
+    const { failed, alreadyMirrored, skippedByServer, succeeded } = outcome;
+    const serverOnlySkipped = skippedByServer.filter((path) => !alreadyMirrored.includes(path));
+
+    if (alreadyMirrored.length) {
+      this.notificationService.show(
+        NotificationType.warning,
+        $localize`Skipped ${alreadyMirrored.length} path(s) that are already mirrored.`
+      );
+    }
+
+    if (serverOnlySkipped.length) {
+      this.notificationService.show(
+        NotificationType.warning,
+        $localize`Skipped ${serverOnlySkipped.length} path(s) that are already mirrored.`
+      );
+    }
+
+    if (succeeded.length) {
+      if (succeeded.length === 1) {
+        this.notificationService.show(
+          NotificationType.success,
+          $localize`Mirroring path '${succeeded[0]}' added to ${this.fsName}`
+        );
+      } else {
+        this.notificationService.show(
+          NotificationType.success,
+          $localize`Added ${succeeded.length} mirroring paths to ${this.fsName}`,
+          succeeded.join('\n')
+        );
+      }
+    }
+
+    if (!failed.length) {
+      return;
+    }
+
+    const title =
+      failed.length === 1
+        ? $localize`Failed to add mirroring path`
+        : $localize`Failed to add ${failed.length} mirroring paths`;
+    const message = failed.map(({ path, detail }) => `${path}: ${detail}`).join('\n');
+    this.notificationService.show(NotificationType.error, title, message);
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.spec.ts
new file mode 100644 (file)
index 0000000..cf5c236
--- /dev/null
@@ -0,0 +1,93 @@
+import { MirroringPathUtils } from './mirroring-path-utils';
+import { PathEntry } from './mirroring-path.model';
+
+describe('MirroringPathUtils', () => {
+  describe('mirroringPathsOverlap', () => {
+    it('should detect exact, ancestor, and descendant overlap', () => {
+      expect(MirroringPathUtils.mirroringPathsOverlap('/volumes/g1/sv1', '/volumes/g1/sv1')).toBe(
+        true
+      );
+      expect(
+        MirroringPathUtils.mirroringPathsOverlap('/volumes/g1/sv1/dir', '/volumes/g1/sv1')
+      ).toBe(true);
+      expect(
+        MirroringPathUtils.mirroringPathsOverlap('/volumes/g1/sv1', '/volumes/g1/sv1/dir')
+      ).toBe(true);
+      expect(MirroringPathUtils.mirroringPathsOverlap('/volumes/g1/sv1', '/volumes/g2/sv1')).toBe(
+        false
+      );
+    });
+  });
+
+  describe('isMirroringPathTracked', () => {
+    it('should match resolved subvolume paths against tracked paths', () => {
+      const tracked = MirroringPathUtils.toTrackedPathSet([
+        '/volumes/cs/g1/64446b51-d39b-436b-991f-0f8e713067ff'
+      ]);
+      expect(MirroringPathUtils.isMirroringPathTracked('/volumes/g1/my-subvol', tracked)).toBe(
+        false
+      );
+      expect(
+        MirroringPathUtils.isMirroringPathTracked(
+          '/volumes/cs/g1/64446b51-d39b-436b-991f-0f8e713067ff',
+          tracked
+        )
+      ).toBe(true);
+    });
+  });
+
+  describe('isGroupPathMirrored', () => {
+    it('should only match exact group paths', () => {
+      const tracked = MirroringPathUtils.toTrackedPathSet(['/volumes/g1/sv1']);
+      expect(MirroringPathUtils.isGroupPathMirrored('/volumes/g1', tracked)).toBe(false);
+      expect(MirroringPathUtils.isGroupPathMirrored('/volumes/g1/sv1', tracked)).toBe(true);
+    });
+  });
+
+  describe('getMirrorPath', () => {
+    it('should require a dir selection before returning a resolved subvolume path', () => {
+      const entry: PathEntry = {
+        fullPath: '/volumes/Group1/B1iu7',
+        expanded: true,
+        subvolumePath: '/volumes/Group1/B1iu7',
+        resolvedSubvolumeRoot: '/volumes/Group1/B1iu7/0ef49a41-2569-41d7-80a6-7849910e62cb',
+        levels: [
+          { options: ['Group1'], selected: 'Group1', kind: 'group' },
+          { options: ['B1iu7'], selected: 'B1iu7', kind: 'subvolume' }
+        ]
+      };
+
+      expect(MirroringPathUtils.getMirrorPath(entry)).toBe('');
+
+      entry.levels.push({
+        options: ['0ef49a41-2569-41d7-80a6-7849910e62cb'],
+        selected: '0ef49a41-2569-41d7-80a6-7849910e62cb',
+        kind: 'dir'
+      });
+
+      expect(MirroringPathUtils.getMirrorPath(entry)).toBe(
+        '/volumes/Group1/B1iu7/0ef49a41-2569-41d7-80a6-7849910e62cb'
+      );
+    });
+  });
+
+  describe('isAlreadyTrackedMirrorError', () => {
+    it('should detect already tracked mirror errors', () => {
+      expect(
+        MirroringPathUtils.isAlreadyTrackedMirrorError(
+          'directory /volumes/Group4/A1/uuid is already tracked'
+        )
+      ).toBe(true);
+      expect(MirroringPathUtils.isAlreadyTrackedMirrorError('permission denied')).toBe(false);
+    });
+  });
+
+  describe('parseMirrorDirectoryList', () => {
+    it('should parse array and json string responses', () => {
+      expect(MirroringPathUtils.parseMirrorDirectoryList(['/volumes/a'])).toEqual(['/volumes/a']);
+      expect(MirroringPathUtils.parseMirrorDirectoryList(JSON.stringify(['/volumes/b']))).toEqual([
+        '/volumes/b'
+      ]);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.ts
new file mode 100644 (file)
index 0000000..53ee693
--- /dev/null
@@ -0,0 +1,194 @@
+import { DEFAULT_SUBVOLUME_GROUP } from '~/app/shared/constants/cephfs.constant';
+
+import { PathEntry } from './mirroring-path.model';
+
+export class MirroringPathUtils {
+  static normalizeMirroringPath(path: string): string {
+    if (!path || path === '/') {
+      return '/';
+    }
+    return path.trim().replace(/\/+$/, '');
+  }
+
+  static normalizeGroupOptionName(groupName: string): string {
+    return !groupName ? DEFAULT_SUBVOLUME_GROUP : groupName;
+  }
+
+  static buildGroupPath(groupName: string): string {
+    return `/volumes/${groupName || DEFAULT_SUBVOLUME_GROUP}`;
+  }
+
+  static buildSubvolumePath(groupName: string, subvolName: string): string {
+    return `/volumes/${groupName || DEFAULT_SUBVOLUME_GROUP}/${subvolName}`;
+  }
+
+  static splitResolvedSubvolumePath(resolvedPath: string): string {
+    const segments = MirroringPathUtils.normalizeMirroringPath(resolvedPath)
+      .split('/')
+      .filter(Boolean);
+    segments.pop();
+    return segments.length ? `/${segments.join('/')}` : '/';
+  }
+
+  static subvolumeNeedsDirSelection(
+    groupName: string,
+    subvolName: string,
+    resolvedPath?: string | null
+  ): boolean {
+    if (!resolvedPath) {
+      return false;
+    }
+    return (
+      MirroringPathUtils.normalizeMirroringPath(resolvedPath) !==
+      MirroringPathUtils.normalizeMirroringPath(
+        MirroringPathUtils.buildSubvolumePath(groupName, subvolName)
+      )
+    );
+  }
+
+  static withResolvedDisplayPath(entry: PathEntry): PathEntry {
+    const groupLevel = entry.levels.find((level) => level.kind === 'group');
+    const subvolLevel = entry.levels.find((level) => level.kind === 'subvolume');
+
+    if (!groupLevel?.selected) {
+      return {
+        ...entry,
+        fullPath: '',
+        subvolumePath: undefined,
+        resolvedSubvolumeRoot: undefined
+      };
+    }
+
+    if (!subvolLevel?.selected) {
+      return {
+        ...entry,
+        subvolumePath: undefined,
+        resolvedSubvolumeRoot: undefined,
+        fullPath: MirroringPathUtils.buildGroupPath(groupLevel.selected)
+      };
+    }
+
+    const dirSegments = entry.levels
+      .filter((level) => level.kind === 'dir' && level.selected)
+      .map((level) => level.selected);
+
+    const logicalSubvolumePath = MirroringPathUtils.buildSubvolumePath(
+      groupLevel.selected,
+      subvolLevel.selected
+    );
+
+    if (!dirSegments.length) {
+      return {
+        ...entry,
+        fullPath: logicalSubvolumePath
+      };
+    }
+
+    if (entry.subvolumePath) {
+      return {
+        ...entry,
+        fullPath: MirroringPathUtils.joinMirroringPath(entry.subvolumePath, dirSegments.join('/'))
+      };
+    }
+
+    return {
+      ...entry,
+      fullPath: MirroringPathUtils.joinMirroringPath(logicalSubvolumePath, dirSegments.join('/'))
+    };
+  }
+
+  static getMirrorPath(entry: PathEntry): string {
+    const groupLevel = entry.levels.find((level) => level.kind === 'group');
+    const subvolLevel = entry.levels.find((level) => level.kind === 'subvolume');
+    const dirSegments = entry.levels
+      .filter((level) => level.kind === 'dir' && level.selected)
+      .map((level) => level.selected);
+
+    if (subvolLevel?.selected && groupLevel?.selected && entry.resolvedSubvolumeRoot) {
+      const needsDir = MirroringPathUtils.subvolumeNeedsDirSelection(
+        groupLevel.selected,
+        subvolLevel.selected,
+        entry.resolvedSubvolumeRoot
+      );
+
+      if (!dirSegments.length) {
+        return needsDir ? '' : entry.resolvedSubvolumeRoot;
+      }
+
+      const parentPath =
+        entry.subvolumePath ??
+        MirroringPathUtils.splitResolvedSubvolumePath(entry.resolvedSubvolumeRoot);
+      return MirroringPathUtils.normalizeMirroringPath(
+        MirroringPathUtils.joinMirroringPath(parentPath, dirSegments.join('/'))
+      );
+    }
+
+    if (!entry.fullPath) {
+      return '';
+    }
+
+    return MirroringPathUtils.normalizeMirroringPath(entry.fullPath);
+  }
+
+  static toTrackedPathSet(paths: string[]): Set<string> {
+    return new Set(paths.map(MirroringPathUtils.normalizeMirroringPath).filter(Boolean));
+  }
+
+  static mirroringPathsOverlap(a: string, b: string): boolean {
+    const left = MirroringPathUtils.normalizeMirroringPath(a);
+    const right = MirroringPathUtils.normalizeMirroringPath(b);
+    if (!left || !right) {
+      return false;
+    }
+    return left === right || left.startsWith(`${right}/`) || right.startsWith(`${left}/`);
+  }
+
+  static isMirroringPathTracked(path: string, trackedPaths: Set<string>): boolean {
+    const normalized = MirroringPathUtils.normalizeMirroringPath(path);
+    if (!normalized || !trackedPaths.size) {
+      return false;
+    }
+
+    for (const tracked of trackedPaths) {
+      if (MirroringPathUtils.mirroringPathsOverlap(normalized, tracked)) {
+        return true;
+      }
+    }
+
+    return false;
+  }
+
+  static isGroupPathMirrored(groupPath: string, trackedPaths: Set<string>): boolean {
+    const normalized = MirroringPathUtils.normalizeMirroringPath(groupPath);
+    if (!normalized || !trackedPaths.size) {
+      return false;
+    }
+    return trackedPaths.has(normalized);
+  }
+
+  static joinMirroringPath(parentPath: string, name: string): string {
+    const parent = MirroringPathUtils.normalizeMirroringPath(parentPath);
+    if (parent === '/') {
+      return `/${name}`;
+    }
+    return `${parent}/${name}`;
+  }
+
+  static parseMirrorDirectoryList(response: unknown): string[] {
+    if (Array.isArray(response)) {
+      return response.filter((path): path is string => typeof path === 'string');
+    }
+    if (typeof response === 'string') {
+      try {
+        return MirroringPathUtils.parseMirrorDirectoryList(JSON.parse(response));
+      } catch {
+        return [];
+      }
+    }
+    return [];
+  }
+
+  static isAlreadyTrackedMirrorError(message: string): boolean {
+    return /already tracked/i.test(message ?? '');
+  }
+}
index f8f6a21cb26384ac165b62c9bd90eb61daa53bfd..157210f1c6723eb476687935812373cb1cbb97d2 100644 (file)
@@ -1,5 +1,38 @@
-export interface MirroringPathSelection {
+export interface DirTreeEntry {
+  name: string;
+  parent: string;
+}
+
+export interface DirLevel {
+  options: string[];
+  selected: string;
+  kind: 'group' | 'subvolume' | 'dir';
+}
+
+export interface PathEntry {
+  fullPath: string;
+  levels: DirLevel[];
+  expanded: boolean;
+  subvolumePath?: string;
+  resolvedSubvolumeRoot?: string;
+}
+
+export function createPathEntry(groupOptions: string[], expanded = true): PathEntry {
+  return {
+    fullPath: '',
+    levels: [{ options: groupOptions, selected: '', kind: 'group' }],
+    expanded
+  };
+}
+
+export interface PathSubmitFailure {
   path: string;
-  subvol?: string;
-  group?: string;
+  detail: string;
+}
+
+export interface PathSubmitOutput {
+  failed: PathSubmitFailure[];
+  alreadyMirrored: string[];
+  skippedByServer: string[];
+  succeeded: string[];
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.html
new file mode 100644 (file)
index 0000000..a3660d7
--- /dev/null
@@ -0,0 +1,101 @@
+<p class="cds--type-heading-03 cds-mb-3"
+   i18n>What do you want to replicate inside this filesystem?</p>
+<p class="cds--type-body-compact-01 cds-mb-5"
+   i18n>A path defines what data will be mirrored to the destination cluster. Select at least one path to continue.</p>
+
+<div class="cds-mb-5">
+  <cd-alert-panel type="info">
+    <div [cdsStack]="'vertical'"
+         [gap]="2">
+      <div class="cds--type-heading-compact-01"
+           i18n>About path selection</div>
+      <div class="cds--type-body-compact-01"
+           i18n>Selecting a higher level path includes all nested content. Select a deeper path for more precise mirroring.</div>
+    </div>
+  </cd-alert-panel>
+</div>
+
+@if (pathsError) {
+  <cd-alert-panel type="error"
+                  class="cds-mb-5">
+    {{ pathsError }}
+  </cd-alert-panel>
+}
+
+<form [formGroup]="formGroup">
+  @for (entry of paths; track $index; let i = $index) {
+    @if (i > 0) {
+      <hr class="cds-mb-5"/>
+    }
+    <div class="cds--tile cds-mb-5"
+         [class.cds-pb-5]="entry.expanded && !entry.levels.length">
+      <div class="cds--row"
+           (click)="toggleExpand(i)">
+        <div class="cds--col-lg-14 cds--col-md-7 cds--col-sm-3">
+          <div cdsStack="horizontal"
+               gap="3">
+            <cd-icon [type]="entry.expanded ? 'chevronUp' : 'chevronDown'"
+                     size="16"></cd-icon>
+            <div cdsStack="vertical"
+                 gap="2">
+              <span class="cds--type-label-01"
+                    i18n>Path</span>
+              <span class="cds--type-body-compact-01">
+                {{ entry.fullPath || '—' }}
+              </span>
+            </div>
+          </div>
+        </div>
+        <div class="cds--col-lg-2 cds--col-md-1 cds--col-sm-1">
+          <cds-icon-button kind="ghost"
+                           size="sm"
+                           (click)="$event.stopPropagation(); removePath(i)">
+            <cd-icon type="trash"
+                     size="16"
+                     class="cds--btn__icon"></cd-icon>
+          </cds-icon-button>
+        </div>
+      </div>
+
+      @if (entry.expanded && entry.levels.length) {
+        <div class="cds-mt-5 cds-pt-5">
+          <div class="cds-mb-3">
+            <span class="cds--type-label-01"
+                  i18n>Path definition</span>
+            <span class="cds--type-helper-text-01"
+                  i18n> (Choose folders step by step to build the path.)</span>
+          </div>
+          <div cdsStack="horizontal"
+               gap="3"
+               class="path-definition-row">
+            @for (level of entry.levels; track $index; let lvl = $index) {
+              @if (lvl > 0) {
+                <span class="cds--type-body-compact-01 path-separator">/</span>
+              }
+              <cds-select [label]="''"
+                          [value]="level.selected"
+                          (valueChange)="onLevelChange(i, lvl, $event)">
+                <option value=""
+                        i18n>-- Select --</option>
+                @for (opt of level.options; track opt) {
+                  <option [value]="opt">{{ opt }}</option>
+                }
+              </cds-select>
+            }
+          </div>
+        </div>
+      }
+    </div>
+  }
+
+  <hr class="cds-mb-5"/>
+
+  <button cdsButton="tertiary"
+          type="button"
+          (click)="addPath()"
+          i18n>
+    Add another path
+    <cd-icon type="add"
+             customClass="cds--btn__icon"></cd-icon>
+  </button>
+</form>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.scss
new file mode 100644 (file)
index 0000000..bc66808
--- /dev/null
@@ -0,0 +1,7 @@
+.path-definition-row {
+  align-items: center;
+}
+
+.path-separator {
+  align-self: center;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.spec.ts
new file mode 100644 (file)
index 0000000..d72eade
--- /dev/null
@@ -0,0 +1,238 @@
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { of } from 'rxjs';
+
+import { MirroringPathsStepComponent } from './mirroring-paths-step.component';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service';
+import { DEFAULT_SUBVOLUME_GROUP } from '~/app/shared/constants/cephfs.constant';
+import { createPathEntry } from '../mirroring-path.model';
+
+describe('MirroringPathsStepComponent', () => {
+  let component: MirroringPathsStepComponent;
+  let fixture: ComponentFixture<MirroringPathsStepComponent>;
+
+  const cephfsServiceMock = {
+    list: jest.fn().mockReturnValue(of([])),
+    lsDir: jest.fn().mockReturnValue(of([])),
+    listMirrorDirectories: jest.fn().mockReturnValue(of([]))
+  };
+
+  const subvolumeGroupServiceMock = {
+    get: jest.fn().mockReturnValue(of([]))
+  };
+
+  beforeEach(async () => {
+    jest.clearAllMocks();
+
+    await TestBed.configureTestingModule({
+      declarations: [MirroringPathsStepComponent],
+      imports: [ReactiveFormsModule],
+      providers: [
+        { provide: CephfsService, useValue: cephfsServiceMock },
+        { provide: CephfsSubvolumeGroupService, useValue: subvolumeGroupServiceMock }
+      ],
+      schemas: [NO_ERRORS_SCHEMA]
+    })
+      .overrideComponent(MirroringPathsStepComponent, {
+        set: { template: '' }
+      })
+      .compileComponents();
+
+    fixture = TestBed.createComponent(MirroringPathsStepComponent);
+    component = fixture.componentInstance;
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should initialize form group with required pathsControl', () => {
+    component.ngOnInit();
+
+    expect(component.formGroup).toBeTruthy();
+    expect(component.pathsControl.hasError('required')).toBe(true);
+    expect(component.paths.length).toBe(1);
+  });
+
+  it('should expose inline validation when no path is selected', () => {
+    component.ngOnInit();
+    component.pathsControl.markAsTouched();
+
+    expect(component.pathsError).toContain('Select at least one path');
+  });
+
+  it('should expose inline validation when only already mirrored paths are selected', fakeAsync(() => {
+    subvolumeGroupServiceMock.get.mockReturnValue(of([{ name: 'g1' }]));
+    cephfsServiceMock.lsDir.mockReturnValue(
+      of([
+        { name: 'g1', parent: '/volumes' },
+        { name: 'sv1', parent: '/volumes/g1' }
+      ])
+    );
+    cephfsServiceMock.listMirrorDirectories.mockReturnValue(of(['/volumes/g1/sv1']));
+
+    component.fsName = 'testfs';
+    component.fsId = 1;
+    component.ngOnInit();
+    tick();
+
+    component.onLevelChange(0, 0, 'g1');
+    component.onLevelChange(0, 1, 'sv1');
+    component.pathsControl.markAsTouched();
+
+    expect(component.pathsControl.hasError('alreadyMirrored')).toBe(true);
+    expect(component.pathsError).toContain('already mirrored');
+  }));
+
+  it('should not load initial data when fsName is missing', () => {
+    component.ngOnInit();
+
+    expect(subvolumeGroupServiceMock.get).not.toHaveBeenCalled();
+    expect(cephfsServiceMock.lsDir).not.toHaveBeenCalled();
+    expect(cephfsServiceMock.listMirrorDirectories).not.toHaveBeenCalled();
+  });
+
+  it('should load groups, directory tree, and tracked paths on init', fakeAsync(() => {
+    subvolumeGroupServiceMock.get.mockReturnValue(of([{ name: 'g1' }, { name: 'g2' }]));
+    cephfsServiceMock.lsDir.mockReturnValue(
+      of([
+        { name: 'g1', parent: '/volumes' },
+        { name: 'sv1', parent: '/volumes/g1' }
+      ])
+    );
+    cephfsServiceMock.listMirrorDirectories.mockReturnValue(of(['/volumes/g1/sv1']));
+
+    component.fsName = 'testfs';
+    component.fsId = 1;
+    component.ngOnInit();
+    tick();
+
+    expect(subvolumeGroupServiceMock.get).toHaveBeenCalledWith('testfs', false);
+    expect(cephfsServiceMock.lsDir).toHaveBeenCalledWith(1, '/volumes', 3);
+    expect(cephfsServiceMock.listMirrorDirectories).toHaveBeenCalledWith('testfs');
+    expect(component.paths[0].levels[0].options).toEqual([DEFAULT_SUBVOLUME_GROUP, 'g1', 'g2']);
+  }));
+
+  it('should resolve fsId from cephfsService when fsId input is not set', fakeAsync(() => {
+    cephfsServiceMock.list.mockReturnValue(of([{ id: 5, mdsmap: { fs_name: 'testfs' } }]));
+
+    component.fsName = 'testfs';
+    component.ngOnInit();
+    tick();
+
+    expect(cephfsServiceMock.list).toHaveBeenCalled();
+    expect(component.fsId).toBe(5);
+    expect(cephfsServiceMock.lsDir).toHaveBeenCalledWith(5, '/volumes', 3);
+  }));
+
+  it('should add and remove path entries', fakeAsync(() => {
+    subvolumeGroupServiceMock.get.mockReturnValue(of([{ name: 'g1' }]));
+    component.fsName = 'testfs';
+    component.fsId = 1;
+    component.ngOnInit();
+    tick();
+
+    component.addPath();
+    expect(component.paths.length).toBe(2);
+
+    component.removePath(1);
+    expect(component.paths.length).toBe(1);
+  }));
+
+  it('should toggle path expansion', () => {
+    component.paths = [createPathEntry([], true)];
+    expect(component.paths[0].expanded).toBe(true);
+
+    component.toggleExpand(0);
+    expect(component.paths[0].expanded).toBe(false);
+  });
+
+  it('should classify submit paths as toAdd or alreadyMirrored', fakeAsync(() => {
+    subvolumeGroupServiceMock.get.mockReturnValue(of([{ name: 'g1' }]));
+    cephfsServiceMock.lsDir.mockReturnValue(
+      of([
+        { name: 'g1', parent: '/volumes' },
+        { name: 'sv1', parent: '/volumes/g1' },
+        { name: 'sv2', parent: '/volumes/g1' }
+      ])
+    );
+    cephfsServiceMock.listMirrorDirectories.mockReturnValue(of(['/volumes/g1/sv1']));
+
+    component.fsName = 'testfs';
+    component.fsId = 1;
+    component.ngOnInit();
+    tick();
+
+    component.onLevelChange(0, 0, 'g1');
+    component.onLevelChange(0, 1, 'sv1');
+
+    expect(component.getSubmitPaths()).toEqual({
+      toAdd: [],
+      alreadyMirrored: ['/volumes/g1/sv1']
+    });
+
+    component.onLevelChange(0, 1, 'sv2');
+    expect(component.getSubmitPaths()).toEqual({
+      toAdd: ['/volumes/g1/sv2'],
+      alreadyMirrored: []
+    });
+  }));
+
+  it('should refresh tracked paths from the server', fakeAsync(() => {
+    subvolumeGroupServiceMock.get.mockReturnValue(of([{ name: 'g1' }]));
+    cephfsServiceMock.lsDir.mockReturnValue(
+      of([
+        { name: 'g1', parent: '/volumes' },
+        { name: 'sv1', parent: '/volumes/g1' }
+      ])
+    );
+    cephfsServiceMock.listMirrorDirectories.mockReturnValue(of([]));
+
+    component.fsName = 'testfs';
+    component.fsId = 1;
+    component.ngOnInit();
+    tick();
+
+    component.onLevelChange(0, 0, 'g1');
+    component.onLevelChange(0, 1, 'sv1');
+    expect(component.getSubmitPaths().alreadyMirrored).toEqual([]);
+
+    cephfsServiceMock.listMirrorDirectories.mockReturnValue(of(['/volumes/g1/sv1']));
+
+    let completed = false;
+    component.refreshTrackedPaths().subscribe(() => {
+      completed = true;
+    });
+    tick();
+
+    expect(completed).toBe(true);
+    expect(component.getSubmitPaths().alreadyMirrored).toEqual(['/volumes/g1/sv1']);
+  }));
+
+  it('should add tracked path locally after successful submit', fakeAsync(() => {
+    subvolumeGroupServiceMock.get.mockReturnValue(of([{ name: 'g1' }]));
+    cephfsServiceMock.lsDir.mockReturnValue(
+      of([
+        { name: 'g1', parent: '/volumes' },
+        { name: 'sv2', parent: '/volumes/g1' }
+      ])
+    );
+
+    component.fsName = 'testfs';
+    component.fsId = 1;
+    component.ngOnInit();
+    tick();
+
+    component.onLevelChange(0, 0, 'g1');
+    component.onLevelChange(0, 1, 'sv2');
+    expect(component.getSubmitPaths().toAdd).toEqual(['/volumes/g1/sv2']);
+
+    component.addTrackedPath('/volumes/g1/sv2');
+    expect(component.getSubmitPaths()).toEqual({
+      toAdd: [],
+      alreadyMirrored: ['/volumes/g1/sv2']
+    });
+  }));
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.ts
new file mode 100644 (file)
index 0000000..750bde1
--- /dev/null
@@ -0,0 +1,334 @@
+import { Component, DestroyRef, inject, Input, OnInit } from '@angular/core';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { FormControl } from '@angular/forms';
+import { forkJoin, Observable, of } from 'rxjs';
+import { catchError, map, switchMap, take } from 'rxjs/operators';
+
+import { DEFAULT_SUBVOLUME_GROUP } from '~/app/shared/constants/cephfs.constant';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service';
+import { CephfsDir } from '~/app/shared/models/cephfs-directory-models';
+import { TearsheetStep } from '~/app/shared/models/tearsheet-step';
+import { MirroringPathUtils } from '../mirroring-path-utils';
+import { createPathEntry, DirTreeEntry, PathEntry } from '../mirroring-path.model';
+
+@Component({
+  selector: 'cd-mirroring-paths-step',
+  templateUrl: './mirroring-paths-step.component.html',
+  styleUrls: ['./mirroring-paths-step.component.scss'],
+  standalone: false
+})
+export class MirroringPathsStepComponent implements OnInit, TearsheetStep {
+  @Input() fsName: string;
+  @Input() fsId: number;
+
+  formGroup!: CdFormGroup;
+  paths: PathEntry[] = [];
+  private trackedPaths = new Set<string>();
+  private cachedGroupNames: string[] = [];
+  private dirTree: DirTreeEntry[] = [];
+  private destroyRef = inject(DestroyRef);
+
+  constructor(
+    private cephfsService: CephfsService,
+    private subvolumeGroupService: CephfsSubvolumeGroupService
+  ) {}
+
+  ngOnInit(): void {
+    this.formGroup = new CdFormGroup({
+      pathsControl: new FormControl<string[]>([], { nonNullable: true })
+    });
+    this.paths = [createPathEntry([])];
+    this.syncFormValue();
+    this.loadInitialData();
+  }
+
+  get pathsControl(): FormControl<string[]> {
+    return this.formGroup.get('pathsControl') as FormControl<string[]>;
+  }
+
+  get pathsError(): string {
+    const control = this.pathsControl;
+    if (!control.invalid || !(control.touched || control.dirty)) {
+      return '';
+    }
+    if (control.hasError('alreadyMirrored')) {
+      return $localize`Selected path(s) are already mirrored. Select a path that is not already mirrored.`;
+    }
+    return $localize`Select at least one path to continue.`;
+  }
+
+  addPath(): void {
+    this.paths.push(createPathEntry([...this.filterAvailableGroupNames()]));
+  }
+
+  removePath(index: number): void {
+    this.paths.splice(index, 1);
+    this.refreshGroupOptions();
+    this.syncFormValue();
+  }
+
+  toggleExpand(index: number): void {
+    this.paths[index].expanded = !this.paths[index].expanded;
+  }
+
+  onLevelChange(pathIndex: number, levelIndex: number, selected: string): void {
+    const entry = this.paths[pathIndex];
+    if (!entry) return;
+
+    const levels = entry.levels.map((level, i) =>
+      i === levelIndex ? { ...level, selected } : level
+    );
+    levels.splice(levelIndex + 1);
+
+    const updatedEntry: PathEntry = { ...entry, levels };
+
+    if (!selected) {
+      this.updatePath(pathIndex, MirroringPathUtils.withResolvedDisplayPath(updatedEntry));
+      return;
+    }
+
+    const kind = levels[levelIndex].kind;
+    if (kind === 'group') {
+      this.handleGroupSelection(pathIndex, updatedEntry, selected);
+    } else if (kind === 'subvolume') {
+      this.handleSubvolumeSelection(pathIndex, updatedEntry, selected);
+    } else {
+      this.handleDirSelection(pathIndex, MirroringPathUtils.withResolvedDisplayPath(updatedEntry));
+    }
+  }
+
+  getSubmitPaths(): { toAdd: string[]; alreadyMirrored: string[] } {
+    const toAdd: string[] = [];
+    const alreadyMirrored: string[] = [];
+    this.paths.forEach((entry, i) => {
+      const rawPath = MirroringPathUtils.getMirrorPath(entry);
+      if (!rawPath) return;
+      const path = MirroringPathUtils.normalizeMirroringPath(rawPath);
+      if (this.isPathAvailable(path, i)) toAdd.push(path);
+      else if (MirroringPathUtils.isMirroringPathTracked(path, this.trackedPaths))
+        alreadyMirrored.push(path);
+    });
+    return { toAdd, alreadyMirrored };
+  }
+
+  addTrackedPath(path: string): void {
+    const normalized = MirroringPathUtils.normalizeMirroringPath(path);
+    if (normalized) {
+      this.trackedPaths.add(normalized);
+      this.syncFormValue();
+    }
+  }
+
+  refreshTrackedPaths(): Observable<void> {
+    if (!this.fsName) return of(undefined);
+    return this.cephfsService.listMirrorDirectories(this.fsName).pipe(
+      map((response) => {
+        this.trackedPaths = MirroringPathUtils.toTrackedPathSet(
+          MirroringPathUtils.parseMirrorDirectoryList(response)
+        );
+        this.syncFormValue();
+      }),
+      catchError(() => of(undefined)),
+      take(1)
+    );
+  }
+
+  private handleGroupSelection(pathIndex: number, entry: PathEntry, groupName: string): void {
+    const groupPath = MirroringPathUtils.buildGroupPath(groupName);
+    const updated = MirroringPathUtils.withResolvedDisplayPath({
+      ...entry,
+      subvolumePath: undefined,
+      resolvedSubvolumeRoot: undefined,
+      fullPath: groupPath
+    });
+
+    const usedSubvols = this.getUsedSubvolumes(groupName, pathIndex);
+    const subvolNames = this.getChildNamesFromTree(groupPath).filter(
+      (name) =>
+        !usedSubvols.has(name) &&
+        !MirroringPathUtils.isMirroringPathTracked(
+          MirroringPathUtils.buildSubvolumePath(groupName, name),
+          this.trackedPaths
+        )
+    );
+
+    this.updatePath(pathIndex, {
+      ...updated,
+      levels: [
+        ...updated.levels,
+        { options: subvolNames, selected: '', kind: 'subvolume' as const }
+      ]
+    });
+  }
+
+  private handleSubvolumeSelection(pathIndex: number, entry: PathEntry, subvolName: string): void {
+    const groupName = entry.levels[0].selected;
+    const subvolPath = MirroringPathUtils.buildSubvolumePath(groupName, subvolName);
+    const dirChildren = this.getChildNamesFromTree(subvolPath).filter((name) =>
+      this.isPathAvailable(MirroringPathUtils.joinMirroringPath(subvolPath, name), pathIndex)
+    );
+
+    this.updatePath(
+      pathIndex,
+      MirroringPathUtils.withResolvedDisplayPath({
+        ...entry,
+        fullPath: subvolPath,
+        subvolumePath: subvolPath,
+        resolvedSubvolumeRoot: subvolPath,
+        levels: [
+          ...entry.levels.slice(0, 2),
+          ...(dirChildren.length
+            ? [{ options: dirChildren, selected: '', kind: 'dir' as const }]
+            : [])
+        ]
+      })
+    );
+  }
+
+  private handleDirSelection(pathIndex: number, entry: PathEntry): void {
+    const parentPath = MirroringPathUtils.normalizeMirroringPath(
+      MirroringPathUtils.getMirrorPath(entry) || entry.fullPath
+    );
+    const childNames = this.getChildNamesFromTree(parentPath).filter((name) =>
+      this.isPathAvailable(MirroringPathUtils.joinMirroringPath(parentPath, name), pathIndex)
+    );
+    this.updatePath(
+      pathIndex,
+      childNames.length
+        ? {
+            ...entry,
+            levels: [...entry.levels, { options: childNames, selected: '', kind: 'dir' as const }]
+          }
+        : entry
+    );
+  }
+
+  private updatePath(pathIndex: number, entry: PathEntry): void {
+    if (!this.paths[pathIndex]) return;
+    this.paths = this.paths.map((c, i) => (i === pathIndex ? entry : c));
+    this.syncFormValue();
+  }
+
+  private syncFormValue(): void {
+    const { toAdd, alreadyMirrored } = this.getSubmitPaths();
+    const control = this.pathsControl;
+    control.setValue(toAdd, { emitEvent: false });
+    if (toAdd.length) {
+      control.setErrors(null);
+    } else if (alreadyMirrored.length) {
+      control.setErrors({ alreadyMirrored: true });
+    } else {
+      control.setErrors({ required: true });
+    }
+  }
+
+  private loadInitialData(): void {
+    if (!this.fsName) return;
+
+    this.resolveFs()
+      .pipe(
+        switchMap((fsId) =>
+          forkJoin([
+            this.subvolumeGroupService.get(this.fsName, false).pipe(catchError(() => of([]))),
+            fsId
+              ? this.cephfsService.lsDir(fsId, '/volumes', 3).pipe(catchError(() => of([])))
+              : of([]),
+            this.cephfsService.listMirrorDirectories(this.fsName).pipe(
+              map((r) => MirroringPathUtils.parseMirrorDirectoryList(r)),
+              catchError(() => of([] as string[]))
+            )
+          ])
+        ),
+        takeUntilDestroyed(this.destroyRef)
+      )
+      .subscribe(([groups, dirs, trackedList]: [{ name: string }[], CephfsDir[], string[]]) => {
+        this.dirTree = dirs.map(({ name, parent }) => ({ name, parent }));
+        this.trackedPaths = MirroringPathUtils.toTrackedPathSet(trackedList);
+
+        const groupNames = groups.map((g) => MirroringPathUtils.normalizeGroupOptionName(g.name));
+        if (!groupNames.includes(DEFAULT_SUBVOLUME_GROUP))
+          groupNames.unshift(DEFAULT_SUBVOLUME_GROUP);
+        this.cachedGroupNames = groupNames;
+
+        const available = this.filterAvailableGroupNames();
+        this.paths = this.paths.map((entry) => ({
+          ...entry,
+          levels: entry.levels.map((level, i) =>
+            i === 0 ? { ...level, options: available } : level
+          )
+        }));
+        this.syncFormValue();
+      });
+  }
+
+  private resolveFs(): Observable<number> {
+    return this.fsId
+      ? of(this.fsId)
+      : this.cephfsService.list().pipe(
+          map(
+            (fs: { id?: number; mdsmap?: { fs_name?: string } }[]) =>
+              fs.find((f) => f.mdsmap?.fs_name === this.fsName)?.id ?? 0
+          ),
+          map((id) => {
+            this.fsId = id;
+            return id;
+          }),
+          catchError(() => of(0))
+        );
+  }
+
+  private refreshGroupOptions(): void {
+    const available = this.filterAvailableGroupNames();
+    this.paths = this.paths.map((entry) => ({
+      ...entry,
+      levels: entry.levels.map((level, i) => (i === 0 ? { ...level, options: available } : level))
+    }));
+  }
+
+  private filterAvailableGroupNames(): string[] {
+    return this.cachedGroupNames.filter(
+      (name) =>
+        !MirroringPathUtils.isGroupPathMirrored(
+          MirroringPathUtils.buildGroupPath(name),
+          this.trackedPaths
+        )
+    );
+  }
+
+  private getUsedSubvolumes(groupName: string, excludeIndex: number): Set<string> {
+    const used = new Set<string>();
+    this.paths.forEach((entry, i) => {
+      if (i === excludeIndex) return;
+      const group = entry.levels.find((l) => l.kind === 'group');
+      const subvol = entry.levels.find((l) => l.kind === 'subvolume');
+      if (group?.selected === groupName && subvol?.selected) used.add(subvol.selected);
+    });
+    return used;
+  }
+
+  private getChildNamesFromTree(parentPath: string): string[] {
+    const normalized = MirroringPathUtils.normalizeMirroringPath(parentPath);
+    return this.dirTree
+      .filter((d) => MirroringPathUtils.normalizeMirroringPath(d.parent) === normalized)
+      .map((d) => d.name)
+      .filter(Boolean)
+      .sort();
+  }
+
+  private isPathAvailable(path: string, pathIndex: number): boolean {
+    const normalized = MirroringPathUtils.normalizeMirroringPath(path);
+    return (
+      !!normalized &&
+      !MirroringPathUtils.isMirroringPathTracked(normalized, this.trackedPaths) &&
+      !this.paths.some((entry, i) => {
+        if (i === pathIndex) return false;
+        const selected = MirroringPathUtils.normalizeMirroringPath(
+          MirroringPathUtils.getMirrorPath(entry)
+        );
+        return selected && MirroringPathUtils.mirroringPathsOverlap(normalized, selected);
+      })
+    );
+  }
+}
index 9d63b627ccde508c08d2fcd7c80c85312ca7c336..a3cc1afd069e1a5a5d7bffaff75dd313d1038505 100644 (file)
@@ -34,6 +34,7 @@ import { CephfsAuthModalComponent } from './cephfs-auth-modal/cephfs-auth-modal.
 import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component';
 import { CephfsMirroringErrorComponent } from './cephfs-mirroring-error/cephfs-mirroring-error.component';
 import { CephfsAddMirroringPathComponent } from './cephfs-add-mirroring-path/cephfs-add-mirroring-path.component';
+import { MirroringPathsStepComponent } from './cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component';
 import { CephfsMirroringFsTabsComponent } from './cephfs-mirroring-fs-tabs/cephfs-mirroring-fs-tabs.component';
 import { CephfsMirroringFsOverviewComponent } from './cephfs-mirroring-fs-overview/cephfs-mirroring-fs-overview.component';
 import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component';
@@ -65,13 +66,15 @@ import {
   TreeviewModule,
   TabsModule,
   NotificationModule,
-  ProgressBarModule
+  ProgressBarModule,
+  FileUploaderModule
 } 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 TrashIcon16 from '@carbon/icons/es/trash-can/16';
 import Renew16 from '@carbon/icons/es/renew/16';
 import ReplicateIcon from '@carbon/icons/es/replicate/32';
 import ReplicateIcon24 from '@carbon/icons/es/replicate/24';
@@ -79,6 +82,10 @@ import ShareIcon from '@carbon/icons/es/share/32';
 import ShareIcon24 from '@carbon/icons/es/share/24';
 import PendingFilled from '@carbon/icons/es/pending--filled/16';
 import DotMark from '@carbon/icons/es/dot-mark/16';
+import ChevronDown16 from '@carbon/icons/es/chevron--down/16';
+import ChevronUp16 from '@carbon/icons/es/chevron--up/16';
+import WarningAltFilled16 from '@carbon/icons/es/warning--alt--filled/16';
+import FolderIcon16 from '@carbon/icons/es/folder/16';
 
 @NgModule({
   imports: [
@@ -118,7 +125,8 @@ import DotMark from '@carbon/icons/es/dot-mark/16';
     TilesModule,
     TagModule,
     NotificationModule,
-    ProgressBarModule
+    ProgressBarModule,
+    FileUploaderModule
   ],
   declarations: [
     CephfsDetailComponent,
@@ -148,7 +156,8 @@ import DotMark from '@carbon/icons/es/dot-mark/16';
     CephfsGenerateTokenComponent,
     CephfsDownloadTokenComponent,
     CephfsSetupMirroringComponent,
-    CephfsAddMirroringPathComponent
+    CephfsAddMirroringPathComponent,
+    MirroringPathsStepComponent
   ],
   providers: [provideCharts(withDefaultRegisterables())],
   schemas: [CUSTOM_ELEMENTS_SCHEMA]
@@ -160,13 +169,18 @@ export class CephfsModule {
       LaunchIcon,
       Close,
       Trash,
+      TrashIcon16,
       Renew16,
       ReplicateIcon,
       ReplicateIcon24,
       ShareIcon,
       ShareIcon24,
       PendingFilled,
-      DotMark
+      DotMark,
+      ChevronDown16,
+      ChevronUp16,
+      WarningAltFilled16,
+      FolderIcon16
     ]);
   }
 }
index a288fcaf26e00b8a7be20282555f9f668138a530..e7a9c3a76ce32f24bf76e34abf9e81576b4fce14 100644 (file)
@@ -64,6 +64,12 @@ describe('CephfsService', () => {
     httpTesting.expectOne('ui-api/cephfs/2/ls_dir?depth=2&path=%252Fsome%252Fpath');
   });
 
+  it('should call statfs', () => {
+    service.statfs(1, '/volumes/g1/sv1').subscribe();
+    const req = httpTesting.expectOne('api/cephfs/1/statfs?path=%252Fvolumes%252Fg1%252Fsv1');
+    expect(req.request.method).toBe('GET');
+  });
+
   it('should call mkSnapshot', () => {
     service.mkSnapshot(3, '/some/path').subscribe();
     const req = httpTesting.expectOne('api/cephfs/3/snapshot?path=%252Fsome%252Fpath');
@@ -121,4 +127,18 @@ describe('CephfsService', () => {
       token: 'token/with/special=chars'
     });
   });
+
+  it('should add mirror directory without encoding path in request body', () => {
+    const path = '/volumes/Group1/A1/64446b51-d39b-436b-991f-0f8e713067ff';
+    service.addMirrorDirectory('testfs', path).subscribe();
+    const req = httpTesting.expectOne('api/cephfs/mirror/directory');
+    expect(req.request.method).toBe('POST');
+    expect(req.request.body).toEqual({ fs_name: 'testfs', path });
+  });
+
+  it('should list mirror directories for a filesystem', () => {
+    service.listMirrorDirectories('testfs').subscribe();
+    const req = httpTesting.expectOne('api/cephfs/mirror/directory/testfs');
+    expect(req.request.method).toBe('GET');
+  });
 });
index 733ece46817e211ae0a4e3bb168744a9234431a3..9baf1d277d52aa05b3165f1842344ea71356ea4d 100644 (file)
@@ -5,7 +5,7 @@ import _ from 'lodash';
 import { Observable } from 'rxjs';
 
 import { cdEncode, cdEncodeNot } from '../decorators/cd-encode';
-import { CephfsDir, CephfsQuotas } from '../models/cephfs-directory-models';
+import { CephfsDir, CephfsDirStatfs, CephfsQuotas } from '../models/cephfs-directory-models';
 import { shareReplay } from 'rxjs/operators';
 import { Daemon } from '../models/cephfs.model';
 
@@ -92,6 +92,11 @@ export class CephfsService {
     return this.http.get<CephfsDir[]>(apiPath).pipe(shareReplay());
   }
 
+  statfs(id: number, path: string): Observable<CephfsDirStatfs> {
+    const params = new HttpParams().set('path', path);
+    return this.http.get<CephfsDirStatfs>(`${this.baseURL}/${id}/statfs`, { params });
+  }
+
   getCephfs(id: number) {
     return this.http.get(`${this.baseURL}/${id}`);
   }
@@ -231,4 +236,15 @@ export class CephfsService {
       { params }
     );
   }
+
+  addMirrorDirectory(@cdEncodeNot fsName: string, @cdEncodeNot path: string): Observable<any> {
+    return this.http.post(`${this.baseURL}/mirror/directory`, {
+      fs_name: fsName,
+      path: path
+    });
+  }
+
+  listMirrorDirectories(@cdEncodeNot fsName: string): Observable<string[]> {
+    return this.http.get<string[]>(`${this.baseURL}/mirror/directory/${fsName}`);
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.scss
new file mode 100644 (file)
index 0000000..cf3680c
--- /dev/null
@@ -0,0 +1,9 @@
+cd-clickable-tile {
+  display: block;
+  height: 100%;
+
+  cds-clickable-tile,
+  .cds--tile {
+    height: 100%;
+  }
+}
index 28a81ad4f3b9519c49644b033163aee19f9df571..81504d371724ee9cc6d9f49c765ef5f4f23d55f3 100644 (file)
@@ -1,8 +1,10 @@
-import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { Component, EventEmitter, Input, Output, ViewEncapsulation } from '@angular/core';
 
 @Component({
   selector: 'cd-clickable-tile',
   templateUrl: './clickable-tile.component.html',
+  styleUrls: ['./clickable-tile.component.scss'],
+  encapsulation: ViewEncapsulation.None,
   standalone: false
 })
 export class ClickableTileComponent {
index 53076bf8a3a4c8478fc691a95672c7f2e9f7e686..6f46e18ca0d683f893d46b882cac9098708fe3b8 100644 (file)
@@ -89,6 +89,7 @@ export enum Icons {
   idea = 'idea',
   userAccessLocked = 'user--access-locked', // User access locked
   chevronDown = 'chevron--down',
+  chevronUp = 'chevron--up',
   connect = 'connect',
   checkmarkOutline = 'checkmark--outline',
   circleDash = 'circle-dash',
@@ -126,7 +127,8 @@ export enum Icons {
   arrowDown = 'arrow--down',
   locked = 'locked', // Access denied, locked state
   cloudMonitoring = 'cloud--monitoring',
-  pendingFilled = 'pending--filled'
+  pendingFilled = 'pending--filled',
+  folder = 'folder'
 }
 
 export enum IconSize {
@@ -180,7 +182,10 @@ export const ICON_TYPE = {
   trash: 'trash-can',
   replicate: 'replicate',
   share: 'share',
-  pendingFilled: 'pending--filled'
+  pendingFilled: 'pending--filled',
+  chevronDown: 'chevron--down',
+  chevronUp: 'chevron--up',
+  folder: 'folder'
 } as const;
 
 export const EMPTY_STATE_IMAGE = {
index a7d933927014ca5d147f0508c462d102f58a4f83..c5d1f2a9e28b073c31834a25f7b7b020db6592d7 100644 (file)
@@ -9,6 +9,12 @@ export class CephfsQuotas {
   max_files?: number;
 }
 
+export class CephfsDirStatfs {
+  bytes: number;
+  files: number;
+  subdirs: number;
+}
+
 export class CephfsDir {
   name: string;
   path: string;
index eebb19f0f6d5de2b8bf449ee85493ff044ffa030..df6dabbb487aeb76784cdbec2f93b03fc5bff093 100644 (file)
@@ -3324,6 +3324,90 @@ paths:
       summary: Get mirror daemon and peers information
       tags:
       - CephfsMirror
+  /api/cephfs/mirror/directory:
+    post:
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                fs_name:
+                  description: File system name
+                  type: string
+                path:
+                  description: Directory path to mirror
+                  type: string
+              required:
+              - fs_name
+              - path
+              type: object
+      responses:
+        '201':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: Resource created.
+        '202':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Add a directory path for snapshot mirroring
+      tags:
+      - CephfsMirror
+  /api/cephfs/mirror/directory/{fs_name}:
+    get:
+      parameters:
+      - description: File system name
+        in: path
+        name: fs_name
+        required: true
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: List snapshot mirrored directories
+      tags:
+      - CephfsMirror
   /api/cephfs/mirror/enable:
     post:
       parameters: []
index 66ad55ce3802df9384da6238f35e83209d84857b..9fde6b40d78795d3b8c011a15a0241edaa64db34 100644 (file)
@@ -3,9 +3,9 @@ import json
 from collections import defaultdict
 
 try:
-    from mock import Mock
+    from mock import Mock, patch
 except ImportError:
-    from unittest.mock import patch, Mock
+    from unittest.mock import Mock, patch
 
 from .. import mgr
 from ..controllers.cephfs import CephFS, CephFSMirror, CephFSMirrorStatus
@@ -247,6 +247,60 @@ class CephFSMirrorTest(ControllerTestCase):
         self.assertIn(error_message, response.get('detail', ''))
         mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_daemon_status')
 
+    def test_add_directory_success(self):
+        fs_name = 'test_fs'
+        path = '/volumes/g1/sv1'
+        expected_result = {'path': path}
+        mock_output = json.dumps(expected_result)
+        mgr.remote = Mock(return_value=(0, mock_output, ''))
+
+        self._post('/api/cephfs/mirror/directory', {
+            'fs_name': fs_name,
+            'path': path
+        })
+        self.assertStatus(200)
+        self.assertJsonBody(expected_result)
+        mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_add_dir', fs_name, path)
+
+    def test_add_directory_error(self):
+        fs_name = 'test_fs'
+        path = '/volumes/g1/sv1'
+        error_message = 'path already mirrored'
+        mgr.remote = Mock(return_value=(1, '', error_message))
+
+        self._post('/api/cephfs/mirror/directory', {
+            'fs_name': fs_name,
+            'path': path
+        })
+        self.assertStatus(400)
+        response = self.json_body()
+        self.assertIn('Failed to add mirroring path', response.get('detail', ''))
+        self.assertIn(error_message, response.get('detail', ''))
+        mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_add_dir', fs_name, path)
+
+    def test_list_directories_success(self):
+        fs_name = 'test_fs'
+        expected_dirs = ['/volumes/g1/sv1', '/volumes/g2/sv2']
+        mock_output = json.dumps(expected_dirs)
+        mgr.remote = Mock(return_value=(0, mock_output, ''))
+
+        self._get(f'/api/cephfs/mirror/directory/{fs_name}')
+        self.assertStatus(200)
+        self.assertJsonBody(expected_dirs)
+        mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_ls', fs_name)
+
+    def test_list_directories_error(self):
+        fs_name = 'test_fs'
+        error_message = 'filesystem not found'
+        mgr.remote = Mock(return_value=(1, '', error_message))
+
+        self._get(f'/api/cephfs/mirror/directory/{fs_name}')
+        self.assertStatus(400)
+        response = self.json_body()
+        self.assertIn('Failed to list mirroring directories', response.get('detail', ''))
+        self.assertIn(error_message, response.get('detail', ''))
+        mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_ls', fs_name)
+
 
 class CephFSMirrorStatusTest(ControllerTestCase):