From: Dnyaneshwari Talwekar Date: Fri, 26 Jun 2026 08:07:20 +0000 (+0530) Subject: mgr/dashboard: Cephfs Mirroring Step 1 - Path Selection X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=2c521881cf33558b48fda5bc41db05182f1c6810;p=ceph.git mgr/dashboard: Cephfs Mirroring Step 1 - Path Selection Fixes: https://tracker.ceph.com/issues/77727 Signed-off-by: Dnyaneshwari Talwekar --- diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 5d30c1cd7ab..f2157381e4d 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -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') diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html index ce4f53d7288..5e6c207fdc6 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.html @@ -11,7 +11,14 @@ i18n-submitButtonLabel (submitRequested)="onSubmit()" (closeRequested)="onCancel()"> - + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts index a0f4a463a83..b1d0ae45c37 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.spec.ts @@ -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; 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 = {}): 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] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts index 341279e3d59..e1b99e46bf2 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/cephfs-add-mirroring-path.component.ts @@ -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 index 00000000000..cf5c2365b23 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.spec.ts @@ -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 index 00000000000..53ee6934e64 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path-utils.ts @@ -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 { + 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): 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): 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 ?? ''); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts index f8f6a21cb26..157210f1c67 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-path.model.ts @@ -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 index 00000000000..a3660d7f42c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.html @@ -0,0 +1,101 @@ +

What do you want to replicate inside this filesystem?

+

A path defines what data will be mirrored to the destination cluster. Select at least one path to continue.

+ +
+ +
+
About path selection
+
Selecting a higher level path includes all nested content. Select a deeper path for more precise mirroring.
+
+
+
+ +@if (pathsError) { + + {{ pathsError }} + +} + +
+ @for (entry of paths; track $index; let i = $index) { + @if (i > 0) { +
+ } +
+
+
+
+ +
+ Path + + {{ entry.fullPath || '—' }} + +
+
+
+
+ + + +
+
+ + @if (entry.expanded && entry.levels.length) { +
+
+ Path definition + (Choose folders step by step to build the path.) +
+
+ @for (level of entry.levels; track $index; let lvl = $index) { + @if (lvl > 0) { + / + } + + + @for (opt of level.options; track opt) { + + } + + } +
+
+ } +
+ } + +
+ + +
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 index 00000000000..bc668080995 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.scss @@ -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 index 00000000000..d72eadef94e --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.spec.ts @@ -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; + + 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 index 00000000000..750bde11868 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-add-mirroring-path/mirroring-paths-step/mirroring-paths-step.component.ts @@ -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(); + 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([], { nonNullable: true }) + }); + this.paths = [createPathEntry([])]; + this.syncFormValue(); + this.loadInitialData(); + } + + get pathsControl(): FormControl { + return this.formGroup.get('pathsControl') as FormControl; + } + + 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 { + 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 { + 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 { + const used = new Set(); + 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); + }) + ); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss index 1a8b5cae759..e69de29bb2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss @@ -1,7 +0,0 @@ -.jump-in-grid { - cd-clickable-tile, - cds-clickable-tile, - .cds--tile { - height: 100%; - } -} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index 9d63b627ccd..a3cc1afd069 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -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 ]); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts index a288fcaf26e..e7a9c3a76ce 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.spec.ts @@ -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'); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts index 733ece46817..9baf1d277d5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -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(apiPath).pipe(shareReplay()); } + statfs(id: number, path: string): Observable { + const params = new HttpParams().set('path', path); + return this.http.get(`${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 { + return this.http.post(`${this.baseURL}/mirror/directory`, { + fs_name: fsName, + path: path + }); + } + + listMirrorDirectories(@cdEncodeNot fsName: string): Observable { + return this.http.get(`${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 index 00000000000..cf3680cb06a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.scss @@ -0,0 +1,9 @@ +cd-clickable-tile { + display: block; + height: 100%; + + cds-clickable-tile, + .cds--tile { + height: 100%; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts index 28a81ad4f3b..81504d37172 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/clickable-tile/clickable-tile.component.ts @@ -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 { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index 53076bf8a3a..6f46e18ca0d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -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 = { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts index a7d93392701..c5d1f2a9e28 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs-directory-models.ts @@ -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; diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index eebb19f0f6d..df6dabbb487 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -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: [] diff --git a/src/pybind/mgr/dashboard/tests/test_cephfs.py b/src/pybind/mgr/dashboard/tests/test_cephfs.py index 66ad55ce380..9fde6b40d78 100644 --- a/src/pybind/mgr/dashboard/tests/test_cephfs.py +++ b/src/pybind/mgr/dashboard/tests/test_cephfs.py @@ -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):