]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Cephfs Mirroring Listing 66195/head
authorPedro Gonzalez Gomez <pegonzal@ibm.com>
Thu, 20 Nov 2025 14:09:03 +0000 (15:09 +0100)
committerDnyaneshwari <dnyaneshwari@li-9c9fbecc-2d5c-11b2-a85c-e2a7cc8a424f.ibm.com>
Wed, 31 Dec 2025 08:03:30 +0000 (13:33 +0530)
Fixes: https://tracker.ceph.com/issues/73781
Signed-off-by: Dnyaneshwari Talwekar <dtalweka@redhat.com>
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-list/cephfs-mirroring-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts

index bf0268e1a59f2c9c5a40bb2ad3253060d80eea09..276a7baea45d8636efeb675abc731d503d30f3ce 100644 (file)
@@ -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 (file)
index 0000000..848dc1d
--- /dev/null
@@ -0,0 +1,12 @@
+<ng-container *ngIf="daemonStatus$ | async as daemonStatus">
+  <cd-table
+    [data]="daemonStatus"
+    [columns]="columns"
+    selectionType="single"
+    [hasDetails]="false"
+    (setExpandedRow)="setExpandedRow($event)"
+    (fetchData)="loadDaemonStatus($event)"
+    (updateSelection)="updateSelection($event)"
+  >
+  </cd-table>
+</ng-container>
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..bd2f222
--- /dev/null
@@ -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<CephfsMirroringListComponent>;
+
+  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 (file)
index 0000000..c207580
--- /dev/null
@@ -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<MirroringRow[]>([]);
+  daemonStatus$: Observable<MirroringRow[]>;
+  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;
+  }
+}
index 9f92b814d9d1625d80f2c76d0385bf933ab2b73c..afe99c867cd4112e777814db99fdc4d13d090954 100644 (file)
@@ -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())]
 })
index 25b23df37f3e41a529d88cec4c8e0d07307825b7..ef5adf9061775979031abd8ddf97c35d03bc36a9 100644 (file)
                             i18n-title
                             *ngIf="permissions.cephfs.read && enabledFeature.cephfs"
                             class="tc_submenuitem tc_submenuitem_file_cephfs"><span i18n>File systems</span></cds-sidenav-item>
+          <cds-sidenav-item route="/cephfs/mirroring"
+                            [useRouter]="true"
+                            title="Mirroring"
+                            i18n-title
+                            *ngIf="permissions.cephfs.read && enabledFeature.cephfs"
+                            class="tc_submenuitem tc_submenuitem_file_cephfs"><span i18n>Mirroring</span></cds-sidenav-item>
           <cds-sidenav-item route="/cephfs/nfs"
                             [useRouter]="true"
                             title="NFS"
index 07235390b8e28a70cfa197caa35965ba7031fb8b..bbd263b66e3dc6670a0dadcafeddda518c93bfdc 100644 (file)
@@ -7,6 +7,7 @@ import { Observable } from 'rxjs';
 import { cdEncode } from '../decorators/cd-encode';
 import { CephfsDir, CephfsQuotas } from '../models/cephfs-directory-models';
 import { shareReplay } from 'rxjs/operators';
+import { Daemon } from '../models/cephfs.model';
 
 @cdEncode
 @Injectable({
@@ -126,4 +127,8 @@ export class CephfsService {
   getUsedPools(): Observable<number[]> {
     return this.http.get<number[]>(`${this.baseUiURL}/used-pools`);
   }
+
+  listDaemonStatus(): Observable<Daemon[]> {
+    return this.http.get<Daemon[]>(`${this.baseURL}/mirror/daemon-status`);
+  }
 }
index b44e564fda98b87c2391ce6b07b4535f3464a82c..955b423b55add6c5f50819a60fe5667565cc2e60 100644 (file)
@@ -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[];