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 {
component: CephfsVolumeFormComponent,
data: { breadcrumbs: ActionLabels.EDIT }
},
+ {
+ path: 'mirroring',
+ component: CephfsMirroringListComponent,
+ data: { breadcrumbs: 'File/Mirroring' }
+ },
{
path: 'nfs',
canActivateChild: [FeatureTogglesGuardService, ModuleStatusGuardService],
--- /dev/null
+<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>
--- /dev/null
+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'
+ });
+ });
+});
--- /dev/null
+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;
+ }
+}
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,
CephfsSnapshotscheduleFormComponent,
CephfsSubvolumeSnapshotsFormComponent,
CephfsMountDetailsComponent,
- CephfsAuthModalComponent
+ CephfsAuthModalComponent,
+ CephfsMirroringListComponent
],
providers: [provideCharts(withDefaultRegisterables())]
})
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"
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({
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`);
+ }
}
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[];