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')
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>
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({
{
provide: Router,
useValue: { navigate: routerNavigateSpy }
- }
+ },
+ { provide: CephfsService, useValue: cephfsServiceMock },
+ { provide: NotificationService, useValue: notificationServiceMock }
],
schemas: [NO_ERRORS_SCHEMA]
})
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();
{
provide: Router,
useValue: { navigate: routerNavigateSpy }
- }
+ },
+ { provide: CephfsService, useValue: cephfsServiceMock },
+ { provide: NotificationService, useValue: notificationServiceMock }
],
schemas: [NO_ERRORS_SCHEMA]
})
-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';
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`;
];
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') ?? '';
}
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 {
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);
+ }
}
--- /dev/null
+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'
+ ]);
+ });
+ });
+});
--- /dev/null
+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 ?? '');
+ }
+}
-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[];
}
--- /dev/null
+<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>
--- /dev/null
+.path-definition-row {
+ align-items: center;
+}
+
+.path-separator {
+ align-self: center;
+}
--- /dev/null
+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']
+ });
+ }));
+});
--- /dev/null
+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);
+ })
+ );
+ }
+}
-.jump-in-grid {
- cd-clickable-tile,
- cds-clickable-tile,
- .cds--tile {
- height: 100%;
- }
-}
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';
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';
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: [
TilesModule,
TagModule,
NotificationModule,
- ProgressBarModule
+ ProgressBarModule,
+ FileUploaderModule
],
declarations: [
CephfsDetailComponent,
CephfsGenerateTokenComponent,
CephfsDownloadTokenComponent,
CephfsSetupMirroringComponent,
- CephfsAddMirroringPathComponent
+ CephfsAddMirroringPathComponent,
+ MirroringPathsStepComponent
],
providers: [provideCharts(withDefaultRegisterables())],
schemas: [CUSTOM_ELEMENTS_SCHEMA]
LaunchIcon,
Close,
Trash,
+ TrashIcon16,
Renew16,
ReplicateIcon,
ReplicateIcon24,
ShareIcon,
ShareIcon24,
PendingFilled,
- DotMark
+ DotMark,
+ ChevronDown16,
+ ChevronUp16,
+ WarningAltFilled16,
+ FolderIcon16
]);
}
}
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');
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');
+ });
});
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';
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}`);
}
{ 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}`);
+ }
}
--- /dev/null
+cd-clickable-tile {
+ display: block;
+ height: 100%;
+
+ cds-clickable-tile,
+ .cds--tile {
+ height: 100%;
+ }
+}
-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 {
idea = 'idea',
userAccessLocked = 'user--access-locked', // User access locked
chevronDown = 'chevron--down',
+ chevronUp = 'chevron--up',
connect = 'connect',
checkmarkOutline = 'checkmark--outline',
circleDash = 'circle-dash',
arrowDown = 'arrow--down',
locked = 'locked', // Access denied, locked state
cloudMonitoring = 'cloud--monitoring',
- pendingFilled = 'pending--filled'
+ pendingFilled = 'pending--filled',
+ folder = 'folder'
}
export enum IconSize {
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 = {
max_files?: number;
}
+export class CephfsDirStatfs {
+ bytes: number;
+ files: number;
+ subdirs: number;
+}
+
export class CephfsDir {
name: string;
path: string;
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: []
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
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):