]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: cephfs mirror add remove path action
authorPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 09:46:51 +0000 (11:46 +0200)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 12:48:14 +0000 (14:48 +0200)
Signed-off-by: Pedro Gonzalez Gomez <pegonzal@ibm.com>
12 files changed:
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.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/models/permissions.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/services/access_control.py
src/pybind/mgr/dashboard/tests/test_cephfs.py

index afc146bd587796a49f9b48b6604aec4dcc15ff59..2906496c0e2071de232d135ce9de7b841d6c8e1c 100644 (file)
@@ -1481,6 +1481,24 @@ class CephFSMirror(RESTController):
             )
         return json.loads(out) if out else {}
 
+    @EndpointDoc("Remove a directory path from snapshot mirroring",
+                 parameters={
+                     'fs_name': (str, 'File system name'),
+                     'path': (str, 'Directory path to remove from mirroring'),
+                 })
+    @Endpoint('DELETE', path='/directory', query_params=['fs_name', 'path'])
+    @DeletePermission
+    def remove_directory(self, fs_name: str, path: str):
+        error_code, out, err = mgr.remote(
+            'mirroring', 'snapshot_mirror_remove_dir', fs_name, path)
+        if error_code != 0:
+            raise DashboardException(
+                msg=f'Failed to remove 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'),
index 65a529a2c4e03b1f2ed3a6ec7fb70e94fed4443d..c0e4760fbb5f642aa9c050e1b2b90992940476ce 100644 (file)
@@ -8,8 +8,20 @@
     [autoReload]="5000"
     [toolHeader]="true"
     [searchableObjects]="true"
+    [showInlineActions]="false"
+    selectionType="single"
+    (updateSelection)="updateSelection($event)"
     (fetchData)="loadMirrorPaths()">
 
+    <cd-table-actions
+      [permission]="permission"
+      [selection]="selection"
+      class="table-actions"
+      id="cephfs-actions"
+      [tableActions]="tableActions"
+    >
+    </cd-table-actions>
+
     <ng-template
       #pathTpl
       let-row="data.row">
index 70c6ea373e40be61bf792ad90c0b74b12b7e601e..dcd21dbcedab794d700b9f5a1c078088daff5caf 100644 (file)
@@ -5,10 +5,16 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testin
 import { ActivatedRoute, convertToParamMap } from '@angular/router';
 import { of, throwError } from 'rxjs';
 
-import { MirrorStatusResponse } from '~/app/shared/models/cephfs.model';
 import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
+import { MirrorStatusResponse } from '~/app/shared/models/cephfs.model';
 import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
 import { FormatterService } from '~/app/shared/services/formatter.service';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths.component';
 import { HttpClientModule } from '@angular/common/http';
 import { RouterTestingModule } from '@angular/router/testing';
@@ -113,7 +119,8 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
 
   beforeEach(async () => {
     const cephfsServiceMock = {
-      getMirrorStatus: jest.fn()
+      getMirrorStatus: jest.fn(),
+      removeMirrorDirectory: jest.fn().mockReturnValue(of({}))
     };
 
     const snapshotScheduleServiceMock = {
@@ -165,6 +172,22 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
               paramMap: of(convertToParamMap({ fsName: 'test-fs' }))
             }
           }
+        },
+        {
+          provide: AuthStorageService,
+          useValue: {
+            getPermissions: () => ({
+              cephfsMirror: { read: true, create: true, update: true, delete: true }
+            })
+          }
+        },
+        {
+          provide: ModalCdsService,
+          useValue: { show: jest.fn() }
+        },
+        {
+          provide: TaskWrapperService,
+          useValue: { wrapTaskAroundCall: jest.fn((args) => args.call) }
         }
       ],
       schemas: [NO_ERRORS_SCHEMA],
@@ -504,9 +527,9 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
 
   describe('onSelectionChange', () => {
     it('should update selection', () => {
-      const mockSelection = { selected: [{ path: '/test' }] } as any;
+      const mockSelection = new CdTableSelection([{ path: '/test' }]);
 
-      component.selection = mockSelection;
+      component.updateSelection(mockSelection);
 
       expect(component.selection).toBe(mockSelection);
     });
@@ -1021,4 +1044,22 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
       }));
     });
   });
+  describe('removePathModal', () => {
+    it('should open high-impact deletion modal for selected path', () => {
+      const modalService = TestBed.inject(ModalCdsService);
+      component.selection = new CdTableSelection([{ path: '/path1' }]);
+      component.fsName = 'test-fs';
+
+      component.removePathModal();
+
+      expect(modalService.show).toHaveBeenCalledWith(
+        DeleteConfirmationModalComponent,
+        expect.objectContaining({
+          impact: DeletionImpact.high,
+          itemNames: ['/path1'],
+          actionDescription: 'remove'
+        })
+      );
+    });
+  });
 });
index 873af47ad864d59f2ba046ba5d23eaa712198e1c..63cbe6aab0cd2fe8c493d0dadb8557316ab02cc9 100644 (file)
@@ -4,18 +4,27 @@ import {
   OnDestroy,
   TemplateRef,
   ViewChild,
-  ViewEncapsulation
+  ViewEncapsulation,
+  inject
 } from '@angular/core';
 import { ActivatedRoute } from '@angular/router';
 import { Subscription } from 'rxjs';
+import { tap } from 'rxjs/operators';
 import { CephfsService } from '~/app/shared/api/cephfs.service';
 import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
+import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
+import { DeletionImpact } from '~/app/shared/enum/delete-confirmation-modal-impact.enum';
+import { Icons, ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
-import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
+import { FinishedTask } from '~/app/shared/models/finished-task';
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { MirrorDirStatus, MirrorStatusResponse } from '~/app/shared/models/cephfs.model';
 import { MirrorPathSchedule } from '~/app/shared/models/snapshot-schedule';
+import { AuthStorageService } from '~/app/shared/services/auth-storage.service';
+import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
+import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
 
 interface MirrorPath {
   path: string;
@@ -58,9 +67,18 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
   @ViewChild('currentSyncSnapshotTpl', { static: true })
   currentSyncSnapshotTpl!: TemplateRef<unknown>;
 
+  private cephfsService = inject(CephfsService);
+  private route = inject(ActivatedRoute);
+  private formatterService = inject(FormatterService);
+  private authStorageService = inject(AuthStorageService);
+  private cdsModalService = inject(ModalCdsService);
+  private taskWrapper = inject(TaskWrapperService);
+
   columns: CdTableColumn[] = [];
   mirrorPaths: MirrorPath[] = [];
   selection = new CdTableSelection();
+  tableActions: CdTableAction[] = [];
+  permission = this.authStorageService.getPermissions().cephfsMirror;
   selectedPath: MirrorPath | null = null;
   sidePanelOpen = false;
   fsName: string = '';
@@ -71,15 +89,9 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
   private subscriptions = new Subscription();
   private mirrorPathsSubscription?: Subscription;
 
-  constructor(
-    private cephfsService: CephfsService,
-    private snapshotScheduleService: CephfsSnapshotScheduleService,
-    private route: ActivatedRoute,
-    private formatterService: FormatterService
-  ) {}
-
   ngOnInit(): void {
     this.initializeColumns();
+    this.initializeTableActions();
     this.fetchFsName();
   }
 
@@ -124,6 +136,48 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
     ];
   }
 
+  initializeTableActions(): void {
+    this.tableActions = [
+      {
+        name: $localize`Remove path`,
+        permission: 'delete',
+        icon: Icons.destroy,
+        click: () => this.removePathModal(),
+        disable: (selection: CdTableSelection) => !selection.hasSingleSelection
+      }
+    ];
+  }
+
+  updateSelection(selection: CdTableSelection): void {
+    this.selection = selection;
+  }
+
+  removePathModal(): void {
+    const path = this.selection.first().path;
+    this.cdsModalService.show(DeleteConfirmationModalComponent, {
+      impact: DeletionImpact.high,
+      itemDescription: $localize`mirror path`,
+      itemNames: [path],
+      actionDescription: 'remove',
+      submitActionObservable: () =>
+        this.taskWrapper
+          .wrapTaskAroundCall({
+            task: new FinishedTask('cephfs/mirroring/path/remove', {
+              fsName: this.fsName,
+              path
+            }),
+            call: this.cephfsService.removeMirrorDirectory(this.fsName, path).pipe(
+              tap(() => {
+                if (this.selectedPath?.path === path) {
+                  this.closeSidePanel();
+                }
+                this.loadMirrorPaths();
+              })
+            )
+          })
+    });
+  }
+
   private fetchFsName(): void {
     this.subscriptions.add(
       this.route.parent?.paramMap.subscribe((paramMap) => {
index 9038d279e71c4b9a87a197e7952b6b94b534bce2..eb7b4270d856580bef2dcb801e5fed1644af94f5 100644 (file)
@@ -20,7 +20,7 @@ describe('CephfsMirroringListComponent', () => {
   };
 
   const authStorageServiceMock = {
-    getPermissions: jest.fn().mockReturnValue({ cephfs: {} as Permission })
+    getPermissions: jest.fn().mockReturnValue({ cephfsMirror: {} as Permission })
   };
 
   beforeEach(async () => {
index ba93cdbcb8097eebb2751d2287b177c5fbdf7b70..f595b15b1d1642f3881e9289063bf4cca13d00f3 100644 (file)
@@ -33,7 +33,7 @@ export class CephfsMirroringListComponent implements OnInit, OnDestroy {
   tableActions: CdTableAction[];
   isSetupModalOpen = false;
   selection = new CdTableSelection();
-  permission = this.authStorageService.getPermissions().cephfs;
+  permission = this.authStorageService.getPermissions().cephfsMirror;
   isPrepareModalOpen = false;
   jumpInTiles: MirroringJumpInTile[] = [];
 
index 103d5b5712e696508351e21551442adb38feabd9..ac58c15b8cd4ad15db8e56bb181d994dba6067b5 100644 (file)
@@ -135,4 +135,17 @@ describe('CephfsService', () => {
     const req = httpTesting.expectOne('api/cephfs/mirror/directory/testfs');
     expect(req.request.method).toBe('GET');
   });
+
+  it('should remove mirror directory using query parameters', () => {
+    const path = '/volumes/Group1/A1/64446b51-d39b-436b-991f-0f8e713067ff';
+    service.removeMirrorDirectory('testfs', path).subscribe();
+    const req = httpTesting.expectOne(
+      (request) =>
+        request.url === 'api/cephfs/mirror/directory' &&
+        request.params.get('fs_name') === 'testfs' &&
+        request.params.get('path') === path
+    );
+    expect(req.request.method).toBe('DELETE');
+    expect(req.request.body).toBeNull();
+  });
 });
index 08d16198f878d4c342cc8ff0b56729e3254b79c7..73d6c572a2760b5c36b5b99b396a9a226bc79527 100644 (file)
@@ -184,4 +184,10 @@ export class CephfsService {
   listMirrorDirectories(@cdEncodeNot fsName: string): Observable<string[]> {
     return this.http.get<string[]>(`${this.baseURL}/mirror/directory/${fsName}`);
   }
+
+  removeMirrorDirectory(@cdEncodeNot fsName: string, @cdEncodeNot path: string): Observable<any> {
+    return this.http.delete(`${this.baseURL}/mirror/directory`, {
+      params: { fs_name: fsName, path }
+    });
+  }
 }
index 838385d840acfc059d2cf19c86764b4209759a51..3fbfbe86ba8e0b824a5d5eddd1920ea61e444c33 100644 (file)
@@ -23,6 +23,7 @@ export class Permissions {
   rbdMirroring: Permission;
   rgw: Permission;
   cephfs: Permission;
+  cephfsMirror: Permission;
   manager: Permission;
   log: Permission;
   user: Permission;
@@ -43,6 +44,7 @@ export class Permissions {
     this.rbdMirroring = new Permission(serverPermissions['rbd-mirroring']);
     this.rgw = new Permission(serverPermissions['rgw']);
     this.cephfs = new Permission(serverPermissions['cephfs']);
+    this.cephfsMirror = new Permission(serverPermissions['cephfs-mirror']);
     this.manager = new Permission(serverPermissions['manager']);
     this.log = new Permission(serverPermissions['log']);
     this.user = new Permission(serverPermissions['user']);
index bc78bf2b5ffdf46683342fed8842139b4f95f436..5027ee031b1b9f06acc4ac9d78311bc804e21eaa 100644 (file)
@@ -330,6 +330,10 @@ export class TaskMessageService {
       ),
       (metadata) => $localize`filesystem mirroring for '${metadata.fsName}'`
     ),
+    'cephfs/mirroring/path/remove': this.newTaskMessage(
+      this.commonOperations.remove,
+      (metadata) => $localize`mirror path '${metadata.path}' from '${metadata.fsName}'`
+    ),
     // RGW operations
     'rgw/bucket/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => {
       return $localize`${metadata.bucket_names[0]}`;
index 392b3ab799960689672390e0d6d6a2d317926eef..59a72a0433d83259975063166c37e614928d24f6 100644 (file)
@@ -272,8 +272,9 @@ POOL_MGR_ROLE = Role(
 
 # CephFS manager role provides all permissions for CephFS related scopes
 CEPHFS_MGR_ROLE = Role(
-    'cephfs-manager', 'allows full permissions for the cephfs scope', {
+    'cephfs-manager', 'allows full permissions for the cephfs and cephfs-mirror scopes', {
         Scope.CEPHFS: [_P.READ, _P.CREATE, _P.UPDATE, _P.DELETE],
+        Scope.CEPHFS_MIRROR: [_P.READ, _P.CREATE, _P.UPDATE, _P.DELETE],
         Scope.GRAFANA: [_P.READ],
         Scope.PROMETHEUS: [_P.READ]
     })
index ec243edb494790916ddbe8a9709562294271a070..27a2a2b9c41517000c9a601c85ff7f4ffa8e8af7 100644 (file)
@@ -1,5 +1,6 @@
 # -*- coding: utf-8 -*-
 import json
+import urllib.parse
 from collections import defaultdict
 
 try:
@@ -278,6 +279,37 @@ class CephFSMirrorTest(ControllerTestCase):
         self.assertIn(error_message, response.get('detail', ''))
         mgr.remote.assert_called_once_with('mirroring', 'snapshot_mirror_add_dir', fs_name, path)
 
+    def test_remove_directory_success(self):
+        fs_name = 'test_fs'
+        path = '/volumes/g1/sv1'
+        expected_result = {}
+        mock_output = json.dumps(expected_result)
+        mgr.remote = Mock(return_value=(0, mock_output, ''))
+
+        self._delete(
+            f'/api/cephfs/mirror/directory?fs_name={fs_name}&path={urllib.parse.quote(path)}'
+        )
+        self.assertStatus(200)
+        self.assertJsonBody(expected_result)
+        mgr.remote.assert_called_once_with(
+            'mirroring', 'snapshot_mirror_remove_dir', fs_name, path)
+
+    def test_remove_directory_error(self):
+        fs_name = 'test_fs'
+        path = '/volumes/g1/sv1'
+        error_message = 'directory not tracked'
+        mgr.remote = Mock(return_value=(1, '', error_message))
+
+        self._delete(
+            f'/api/cephfs/mirror/directory?fs_name={fs_name}&path={urllib.parse.quote(path)}'
+        )
+        self.assertStatus(400)
+        response = self.json_body()
+        self.assertIn('Failed to remove mirroring path', response.get('detail', ''))
+        self.assertIn(error_message, response.get('detail', ''))
+        mgr.remote.assert_called_once_with(
+            'mirroring', 'snapshot_mirror_remove_dir', fs_name, path)
+
     def test_list_directories_success(self):
         fs_name = 'test_fs'
         expected_dirs = ['/volumes/g1/sv1', '/volumes/g2/sv2']