)
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'),
[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">
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';
beforeEach(async () => {
const cephfsServiceMock = {
- getMirrorStatus: jest.fn()
+ getMirrorStatus: jest.fn(),
+ removeMirrorDirectory: jest.fn().mockReturnValue(of({}))
};
const snapshotScheduleServiceMock = {
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],
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);
});
}));
});
});
+ 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'
+ })
+ );
+ });
+ });
});
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;
@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 = '';
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();
}
];
}
+ 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) => {
};
const authStorageServiceMock = {
- getPermissions: jest.fn().mockReturnValue({ cephfs: {} as Permission })
+ getPermissions: jest.fn().mockReturnValue({ cephfsMirror: {} as Permission })
};
beforeEach(async () => {
tableActions: CdTableAction[];
isSetupModalOpen = false;
selection = new CdTableSelection();
- permission = this.authStorageService.getPermissions().cephfs;
+ permission = this.authStorageService.getPermissions().cephfsMirror;
isPrepareModalOpen = false;
jumpInTiles: MirroringJumpInTile[] = [];
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();
+ });
});
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 }
+ });
+ }
}
rbdMirroring: Permission;
rgw: Permission;
cephfs: Permission;
+ cephfsMirror: Permission;
manager: Permission;
log: Permission;
user: Permission;
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']);
),
(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]}`;
# 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]
})
# -*- coding: utf-8 -*-
import json
+import urllib.parse
from collections import defaultdict
try:
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']