From 26a0ce7ca68c7023f88636b77fa5b04205742eec Mon Sep 17 00:00:00 2001 From: Pedro Gonzalez Gomez Date: Thu, 20 Nov 2025 15:09:03 +0100 Subject: [PATCH] mgr/dashboard: Cephfs Mirroring Listing Fixes: https://tracker.ceph.com/issues/73781 Signed-off-by: Dnyaneshwari Talwekar --- .../frontend/src/app/app-routing.module.ts | 6 + .../cephfs-mirroring-list.component.html | 12 ++ .../cephfs-mirroring-list.component.scss | 0 .../cephfs-mirroring-list.component.spec.ts | 122 ++++++++++++++++++ .../cephfs-mirroring-list.component.ts | 100 ++++++++++++++ .../src/app/ceph/cephfs/cephfs.module.ts | 4 +- .../navigation/navigation.component.html | 6 + .../src/app/shared/api/cephfs.service.ts | 5 + .../src/app/shared/models/cephfs.model.ts | 42 ++++++ 9 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index bf0268e1a59f..276a7baea45d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -59,6 +59,7 @@ import { SmbJoinAuthListComponent } from './ceph/smb/smb-join-auth-list/smb-join import { SmbUsersgroupsListComponent } from './ceph/smb/smb-usersgroups-list/smb-usersgroups-list.component'; import { SmbOverviewComponent } from './ceph/smb/smb-overview/smb-overview.component'; import { MultiClusterFormComponent } from './ceph/cluster/multi-cluster/multi-cluster-form/multi-cluster-form.component'; +import { CephfsMirroringListComponent } from './ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component'; @Injectable() export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver { @@ -416,6 +417,11 @@ const routes: Routes = [ component: CephfsVolumeFormComponent, data: { breadcrumbs: ActionLabels.EDIT } }, + { + path: 'mirroring', + component: CephfsMirroringListComponent, + data: { breadcrumbs: 'File/Mirroring' } + }, { path: 'nfs', canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService], diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html new file mode 100644 index 000000000000..848dc1dc57d7 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html @@ -0,0 +1,12 @@ + + + + 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 new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts new file mode 100644 index 000000000000..bd2f222b24cd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts @@ -0,0 +1,122 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { of } from 'rxjs'; + +import { CephfsMirroringListComponent } from './cephfs-mirroring-list.component'; +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model'; + +describe('CephfsMirroringListComponent', () => { + let component: CephfsMirroringListComponent; + let fixture: ComponentFixture; + + const cephfsServiceMock = { + listDaemonStatus: jest.fn() + }; + + beforeEach(async () => { + jest.clearAllMocks(); + + await TestBed.configureTestingModule({ + declarations: [CephfsMirroringListComponent], + providers: [ActionLabelsI18n, { provide: CephfsService, useValue: cephfsServiceMock }] + }).compileComponents(); + + fixture = TestBed.createComponent(CephfsMirroringListComponent); + component = fixture.componentInstance; + }); + + it('should initialize columns correctly on ngOnInit', () => { + component.ngOnInit(); + + expect(component.columns.length).toBe(5); + expect(component.columns[0].prop).toBe('remote_cluster_name'); + }); + + it('should call loadDaemonStatus inside ngOnInit', () => { + const loadSpy = jest.spyOn(component, 'loadDaemonStatus'); + component.ngOnInit(); + expect(loadSpy).toHaveBeenCalledTimes(1); + }); + + it('should map daemon status to MirroringRow[] correctly', () => { + const mockData: Daemon[] = [ + { + daemon_id: 1, + filesystems: [ + { + filesystem_id: 10, + name: 'fs1', + directory_count: 3, + peers: [ + { + remote: { + cluster_name: 'clusterA', + fs_name: 'fsA', + client_name: 'clientA' + }, + uuid: '', + stats: undefined + } + ], + id: '' + } + ] + } + ]; + + cephfsServiceMock.listDaemonStatus.mockReturnValue(of(mockData)); + + let emitted: MirroringRow[] = []; + + component.ngOnInit(); + component.daemonStatus$.subscribe((v) => (emitted = v || [])); + component.loadDaemonStatus(); + + expect(emitted.length).toBe(1); + expect(emitted[0]).toEqual({ + remote_cluster_name: 'clusterA', + local_fs_name: 'fs1', + fs_name: 'fsA', + client_name: 'clientA', + directory_count: 3, + id: '1-10' + }); + }); + + it('should handle empty peers and map "-" values', () => { + const mockData: Daemon[] = [ + { + daemon_id: 2, + filesystems: [ + { + filesystem_id: 20, + name: 'fs2', + directory_count: 5, + peers: [], + id: '' + } + ] + } + ]; + + cephfsServiceMock.listDaemonStatus.mockReturnValue(of(mockData)); + + let emitted: MirroringRow[] = []; + + component.ngOnInit(); + component.daemonStatus$.subscribe((v) => (emitted = v || [])); + component.loadDaemonStatus(); + + expect(emitted.length).toBe(1); + expect(emitted[0]).toEqual({ + remote_cluster_name: '-', + local_fs_name: 'fs2', + fs_name: 'fs2', + client_name: '-', + directory_count: 5, + peerId: '-', + id: '2-20' + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts new file mode 100644 index 000000000000..c207580975d0 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts @@ -0,0 +1,100 @@ +import { Component, ViewChild, OnInit } from '@angular/core'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { catchError, switchMap } from 'rxjs/operators'; +import { TableComponent } from '~/app/shared/datatable/table/table.component'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { CdTableAction } from '~/app/shared/models/cd-table-action'; +import { URLBuilderService } from '~/app/shared/services/url-builder.service'; +import { Daemon, MirroringRow } from '~/app/shared/models/cephfs.model'; + +export const MIRRORING_PATH = 'cephfs/mirroring'; +@Component({ + selector: 'cd-cephfs-mirroring-list', + templateUrl: './cephfs-mirroring-list.component.html', + styleUrls: ['./cephfs-mirroring-list.component.scss'], + standalone: false, + providers: [{ provide: URLBuilderService, useValue: new URLBuilderService(MIRRORING_PATH) }] +}) +export class CephfsMirroringListComponent implements OnInit { + @ViewChild('table', { static: true }) table: TableComponent; + + columns: CdTableColumn[]; + selection = new CdTableSelection(); + subject$ = new BehaviorSubject([]); + daemonStatus$: Observable; + context: CdTableFetchDataContext; + tableActions: CdTableAction[]; + + constructor(public actionLabels: ActionLabelsI18n, private cephfsService: CephfsService) {} + + ngOnInit() { + this.columns = [ + { + name: $localize`Remote cluster`, + prop: 'remote_cluster_name', + flexGrow: 2 + }, + { name: $localize`Local filesystem`, prop: 'local_fs_name', flexGrow: 2 }, + { name: $localize`Remote filesystem`, prop: 'fs_name', flexGrow: 2 }, + { name: $localize`Remote client`, prop: 'client_name', flexGrow: 2 }, + { name: $localize`Snapshot directories`, prop: 'directory_count', flexGrow: 1 } + ]; + + this.daemonStatus$ = this.subject$.pipe( + switchMap(() => + this.cephfsService.listDaemonStatus()?.pipe( + switchMap((daemons: Daemon[]) => { + const result: MirroringRow[] = []; + + daemons.forEach((d) => { + d.filesystems.forEach((fs) => { + if (!fs.peers || fs.peers.length === 0) { + result.push({ + remote_cluster_name: '-', + local_fs_name: fs.name, + fs_name: fs.name, + client_name: '-', + directory_count: fs.directory_count, + peerId: '-', + id: `${d.daemon_id}-${fs.filesystem_id}` + }); + } else { + fs.peers.forEach((peer) => { + result.push({ + remote_cluster_name: peer.remote.cluster_name, + local_fs_name: fs.name, + fs_name: peer.remote.fs_name, + client_name: peer.remote.client_name, + directory_count: fs.directory_count, + id: `${d.daemon_id}-${fs.filesystem_id}` + }); + }); + } + }); + }); + + return of(result); + }), + catchError(() => { + this.context?.error(); + return of(null); + }) + ) + ) + ); + + this.loadDaemonStatus(); + } + + loadDaemonStatus() { + this.subject$.next([]); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } +} 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 9f92b814d9d1..afe99c867cd4 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 @@ -31,6 +31,7 @@ import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapsh import { CephfsSnapshotscheduleFormComponent } from './cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component'; import { CephfsMountDetailsComponent } from './cephfs-mount-details/cephfs-mount-details.component'; import { CephfsAuthModalComponent } from './cephfs-auth-modal/cephfs-auth-modal.component'; +import { CephfsMirroringListComponent } from './cephfs-mirroring-list/cephfs-mirroring-list.component'; import { ButtonModule, CheckboxModule, @@ -106,7 +107,8 @@ import Trash from '@carbon/icons/es/trash-can/32'; CephfsSnapshotscheduleFormComponent, CephfsSubvolumeSnapshotsFormComponent, CephfsMountDetailsComponent, - CephfsAuthModalComponent + CephfsAuthModalComponent, + CephfsMirroringListComponent ], providers: [provideCharts(withDefaultRegisterables())] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html index 25b23df37f3e..ef5adf906177 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html @@ -270,6 +270,12 @@ i18n-title *ngIf="permissions.cephfs.read && enabledFeature.cephfs" class="tc_submenuitem tc_submenuitem_file_cephfs">File systems + Mirroring { return this.http.get(`${this.baseUiURL}/used-pools`); } + + listDaemonStatus(): Observable { + return this.http.get(`${this.baseURL}/mirror/daemon-status`); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts index b44e564fda98..955b423b55ad 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts @@ -11,3 +11,45 @@ export const PERMISSION_NAMES = { QUOTA: 'quota', ROOTSQUASH: 'rootSquash' } as const; + +export interface RemoteInfo { + client_name: string; + cluster_name: string; + fs_name: string; +} + +export interface PeerStats { + failure_count: number; + recovery_count: number; +} + +export interface Peer { + uuid: string; + remote: RemoteInfo; + stats: PeerStats; +} + +export interface Filesystem { + filesystem_id: number; + name: string; + directory_count: number; + peers: Peer[]; + id: string; +} + +export interface Daemon { + daemon_id: number; + filesystems: Filesystem[]; +} + +export interface MirroringRow { + remote_cluster_name: string; + fs_name: string; + local_fs_name?: string; + client_name: string; + directory_count: number; + peerId?: string; + id?: string; +} + +export type DaemonResponse = Daemon[]; -- 2.47.3