From: Aashish Sharma Date: Wed, 24 Jun 2026 10:02:46 +0000 (+0530) Subject: mgr/dashboard: Add Details tab -> Mirror path Listing X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=23aabf61ed325044006db590f15abe880dfa46b1;p=ceph.git mgr/dashboard: Add Details tab -> Mirror path Listing Signed-off-by: Aashish Sharma --- diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 62f994b81fc..5d30c1cd7ab 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -1412,7 +1412,8 @@ class CephFSMirror(RESTController): @Endpoint('GET', path='/snapshot-mirror-status') @ReadPermission def snapshot_mirror_status(self, fs_name: str, mirrored_dir_path=None, peer_uuid=None): - error_code, out, err = mgr.remote('mirroring', 'snapshot_mirror_status', fs_name, mirrored_dir_path, peer_uuid) + error_code, out, err = mgr.remote('mirroring', 'snapshot_mirror_status', + fs_name, mirrored_dir_path, peer_uuid) if error_code != 0: raise DashboardException( msg=f'Failed to get Cephfs snapshot mirror status: {err}', diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html index f1279f35f49..1549bf945cc 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html @@ -1,57 +1,157 @@ -
- Mirror paths -
- - - - - {{ row.path }} - - - - -
- - {{ row.syncStatus | titlecase }} +Mirror paths +
+ + + + + {{ row.path }} + + + + +
+ + + {{ row.syncStatus | titlecase }} +
+
+ + +
+
{{ row.currentSyncSnapshot }}
+
+ Time remaining: {{ row.currentSyncEta }} +
+
+
+
+
+ + +
+
+ + +
+ +
+
Replication status
+ +
+
Sync status
+
+ + + {{ selectedPath.syncStatus | titlecase }} +
+
+ +
+
Current snapshot
+
{{ selectedPath.currentSyncSnapshot }}
+
+ +
+
Last replicated snapshot
+
{{ selectedPath.lastSyncedSnapshot }}
+
- - - -
-
{{ row.currentSyncSnapshot }}
-
- ETA: {{ row.currentSyncEta }} + + +
+
+ +
Current snapshot progress
+ +
+
+ {{ selectedPath.syncProgress }}% synced +
+ + +
+ +
+
Files Synced
+
{{ selectedPath.filesSynced }}/{{ selectedPath.totalFiles }}
+
+ +
+
Bytes Synced
+
{{ selectedPath.bytesSynced | dimlessBinary }}/{{ selectedPath.totalBytes | dimlessBinary }}
+
+ +
+
Estimated time remaining
+
{{ selectedPath.currentSyncEta }}
- - -
+
+ - - -
+ +
+
+ Snapshot information will be displayed here. +
+
+
+ + +
+
+ Schedule policy information will be displayed here. +
+
+
+
+
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.scss index 339a8fcc78c..e69de29bb2d 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.scss +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.scss @@ -1,29 +0,0 @@ -.cephfs-mirroring-fs-mirror-paths { - .path-link { - color: var(--cds-link-primary); - text-decoration: none; - cursor: pointer; - - &:hover { - text-decoration: none; - } - } - - .text-success { - fill: var(--cds-support-success); - } - - .text-info { - fill: var(--cds-support-info); - } - - .text-danger { - fill: var(--cds-support-error); - } - - .text-muted { - fill: var(--cds-text-secondary); - } -} - -// Made with Bob diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts new file mode 100644 index 00000000000..9670380c0be --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts @@ -0,0 +1,484 @@ +import { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, convertToParamMap } from '@angular/router'; +import { of, throwError } from 'rxjs'; + +import { CephfsService, SnapshotMirrorStatusResponse } from '~/app/shared/api/cephfs.service'; +import { FormatterService } from '~/app/shared/services/formatter.service'; +import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths.component'; + +describe('CephfsMirroringFsMirrorPathsComponent', () => { + let component: CephfsMirroringFsMirrorPathsComponent; + let fixture: ComponentFixture; + let cephfsService: any; + let formatterService: any; + + const mockSnapshotMirrorStatusResponse: SnapshotMirrorStatusResponse = { + metrics: { + '/path1': { + peer: { + 'peer-uuid-1': { + state: 'syncing', + current_syncing_snap: { + id: 4, + name: 'snap-current', + 'sync-mode': 'delta', + avg_read_throughput_bytes: '0.00 B/s', + avg_write_throughput_bytes: '0.00 B/s', + crawl: { + state: 'completed', + duration: '0s' + }, + datasync_queue_wait: { + state: 'completed', + duration: '0s' + }, + bytes: { + sync_bytes: '1.50 GiB', + total_bytes: '3.00 GiB', + sync_percent: '50.00%' + }, + files: { + sync_files: 100, + total_files: 200, + sync_percent: '50.00%' + }, + eta: 'calculating...' + }, + last_synced_snap: { + id: 1, + name: 'snap-last', + sync_time_stamp: '1583.101609s' + }, + snaps_synced: 10, + snaps_deleted: 2, + snaps_renamed: 1 + } + } + }, + '/path2': { + peer: { + 'peer-uuid-2': { + state: 'idle', + current_syncing_snap: { + id: 5, + name: 'snap-current-2', + bytes: { + sync_bytes: '1.00 GiB', + total_bytes: '2.00 GiB', + sync_percent: '50.00%' + }, + files: { + sync_files: 50, + total_files: 100, + sync_percent: '50.00%' + }, + eta: '10s' + }, + last_synced_snap: { + id: 2, + name: 'snap-last-2' + }, + snaps_synced: 5, + snaps_deleted: 1 + } + } + } + } + }; + + beforeEach(async () => { + const cephfsServiceMock = { + getSnapshotMirrorStatus: jest.fn() + }; + + const formatterServiceMock = { + toBytes: jest.fn((value: string) => { + const match = value.match(/([\d.]+)\s*(\w+)/); + if (!match) return 0; + const val = parseFloat(match[1]); + const unit = match[2].toUpperCase(); + const multipliers: { [key: string]: number } = { + B: 1, + KIB: 1024, + MIB: 1024 * 1024, + GIB: 1024 * 1024 * 1024, + TIB: 1024 * 1024 * 1024 * 1024, + KB: 1024, + MB: 1024 * 1024, + GB: 1024 * 1024 * 1024, + TB: 1024 * 1024 * 1024 * 1024 + }; + return val * (multipliers[unit] || 1); + }) + }; + + await TestBed.configureTestingModule({ + declarations: [CephfsMirroringFsMirrorPathsComponent], + providers: [ + { + provide: CephfsService, + useValue: cephfsServiceMock + }, + { + provide: FormatterService, + useValue: formatterServiceMock + }, + { + provide: ActivatedRoute, + useValue: { + parent: { + paramMap: of(convertToParamMap({ fsName: 'test-fs' })) + } + } + } + ], + schemas: [NO_ERRORS_SCHEMA] + }).compileComponents(); + + cephfsService = TestBed.inject(CephfsService); + formatterService = TestBed.inject(FormatterService); + + fixture = TestBed.createComponent(CephfsMirroringFsMirrorPathsComponent); + component = fixture.componentInstance; + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should initialize columns and fetch fsName on init', () => { + cephfsService.getSnapshotMirrorStatus.mockReturnValue(of(mockSnapshotMirrorStatusResponse)); + + component.ngOnInit(); + + expect(component.columns).toBeDefined(); + expect(component.columns.length).toBe(5); + expect(component.columns[0].prop).toBe('path'); + expect(component.columns[1].prop).toBe('syncStatus'); + expect(component.columns[2].prop).toBe('snapshotCount'); + expect(component.columns[3].prop).toBe('currentSyncSnapshot'); + expect(component.columns[4].prop).toBe('lastSyncedSnapshot'); + + // Verify fsName is fetched and data is loaded + expect(component.fsName).toBe('test-fs'); + expect(cephfsService.getSnapshotMirrorStatus).toHaveBeenCalledWith('test-fs'); + }); + + describe('parseMirrorStatus', () => { + it('should return empty array when data is null', () => { + const result = component.parseMirrorStatus(null as any); + expect(result).toEqual([]); + }); + + it('should return empty array when data.metrics is undefined', () => { + const result = component.parseMirrorStatus({} as any); + expect(result).toEqual([]); + }); + + it('should parse mirror status with current_syncing_snap structure', () => { + const result = component.parseMirrorStatus(mockSnapshotMirrorStatusResponse); + + expect(result.length).toBe(2); + + expect(result[0].path).toBe('/path1'); + expect(result[0].syncStatus).toBe('syncing'); + expect(result[0].currentSyncSnapshot).toBe('snap-current'); + expect(result[0].currentSyncEta).toBe('calculating...'); + expect(result[0].currentSyncMode).toBe('delta'); + expect(result[0].lastSyncedSnapshot).toBe('snap-last'); + expect(result[0].lastSyncedTime).toBe('1583.101609s'); + expect(result[0].snapshotCount).toBe(10); + expect(result[0].checkpointCount).toBe(2); + expect(result[0].renamedSnapshotCount).toBe(1); + expect(result[0].filesSynced).toBe(100); + expect(result[0].totalFiles).toBe(200); + expect(result[0].syncProgress).toBe(50); + expect(result[0].crawlState).toBe('completed'); + expect(result[0].crawlDuration).toBe('0s'); + expect(result[0].datasyncQueueWaitState).toBe('completed'); + expect(result[0].datasyncQueueWaitDuration).toBe('0s'); + expect(result[0].avgReadThroughput).toBe('0.00 B/s'); + expect(result[0].avgWriteThroughput).toBe('0.00 B/s'); + + expect(result[1].path).toBe('/path2'); + expect(result[1].syncStatus).toBe('idle'); + expect(result[1].currentSyncSnapshot).toBe('snap-current-2'); + expect(result[1].lastSyncedSnapshot).toBe('snap-last-2'); + expect(result[1].snapshotCount).toBe(5); + expect(result[1].checkpointCount).toBe(1); + expect(result[1].filesSynced).toBe(50); + expect(result[1].totalFiles).toBe(100); + expect(result[1].bytesSynced).toBe(1073741824); + expect(result[1].totalBytes).toBe(2147483648); + expect(result[1].syncProgress).toBe(50); + }); + + it('should skip paths without peer data', () => { + const dataWithoutPeer: SnapshotMirrorStatusResponse = { + metrics: { + '/path1': {} as any + } + }; + + const result = component.parseMirrorStatus(dataWithoutPeer); + expect(result).toEqual([]); + }); + + it('should use default values when optional fields are missing', () => { + const minimalData: SnapshotMirrorStatusResponse = { + metrics: { + '/path1': { + peer: { + 'peer-1': { + state: 'idle' + } + } + } + } + }; + + const result = component.parseMirrorStatus(minimalData); + + expect(result.length).toBe(1); + expect(result[0].syncStatus).toBe('idle'); + expect(result[0].currentSyncSnapshot).toBe('-'); + expect(result[0].lastSyncedSnapshot).toBe('-'); + expect(result[0].snapshotCount).toBe(0); + expect(result[0].checkpointCount).toBe(0); + expect(result[0].syncProgress).toBe(0); + }); + }); + + describe('parsePercent', () => { + it('should parse percentage string', () => { + const result = component['parsePercent']('78.00%'); + expect(result).toBe(78); + }); + + it('should return 0 for undefined input', () => { + const result = component['parsePercent'](undefined); + expect(result).toBe(0); + }); + + it('should return 0 for invalid string format', () => { + const result = component['parsePercent']('invalid'); + expect(result).toBe(0); + }); + }); + + describe('parseByteValue', () => { + it('should parse byte value from string format', () => { + const result = component['parseByteValue']('1.5 GiB'); + + expect(result).toBeGreaterThan(0); + expect(formatterService.toBytes).toHaveBeenCalledWith('1.5GiB', 0); + }); + + it('should return 0 for undefined input', () => { + const result = component['parseByteValue'](undefined); + + expect(result).toBe(0); + }); + + it('should return 0 for invalid string format', () => { + formatterService.toBytes.mockReturnValueOnce(0); + const result = component['parseByteValue']('invalid'); + + expect(result).toBe(0); + }); + + it('should handle different byte units', () => { + component['parseByteValue']('512 MB'); + + expect(formatterService.toBytes).toHaveBeenCalledWith('512MB', 0); + }); + }); + + describe('calculateSyncProgress', () => { + it('should return file progress when available', () => { + const result = component['calculateSyncProgress'](75, 50, 100, 500, 1000, 50); + expect(result).toBe(75); + }); + + it('should calculate progress from file count when file progress is 0', () => { + const result = component['calculateSyncProgress'](0, 50, 100, 0, 0, 0); + expect(result).toBe(50); + }); + + it('should calculate progress from byte percentage when file progress and count are 0', () => { + const result = component['calculateSyncProgress'](0, 0, 0, 500, 1000, 50); + expect(result).toBe(50); + }); + + it('should calculate progress from byte count when percentages are unavailable', () => { + const result = component['calculateSyncProgress'](0, 0, 0, 500, 1000, 0); + expect(result).toBe(50); + }); + + it('should return 0 when no progress data is available', () => { + const result = component['calculateSyncProgress'](0, 0, 0, 0, 0, 0); + expect(result).toBe(0); + }); + + it('should round progress to nearest integer', () => { + const result = component['calculateSyncProgress'](0, 33, 100, 0, 0, 0); + expect(result).toBe(33); + }); + }); + + describe('extractPeerInfo', () => { + it('should extract first peer info from path data', () => { + const pathData = { + peer: { + 'peer-1': { state: 'syncing' }, + 'peer-2': { state: 'idle' } + } + }; + + const result = component['extractPeerInfo'](pathData as any); + + expect(result).toBeDefined(); + expect(result?.state).toBe('syncing'); + }); + + it('should return null when peer object is empty', () => { + const pathData = { peer: {} }; + + const result = component['extractPeerInfo'](pathData as any); + + expect(result).toBeNull(); + }); + + it('should return null when peer is undefined', () => { + const pathData = { peer: undefined }; + + const result = component['extractPeerInfo'](pathData as any); + + expect(result).toBeNull(); + }); + }); + + describe('loadMirrorPaths', () => { + it('should load mirror paths successfully', () => { + cephfsService.getSnapshotMirrorStatus.mockReturnValue(of(mockSnapshotMirrorStatusResponse)); + component.fsName = 'test-fs'; + + component.loadMirrorPaths(); + + expect(cephfsService.getSnapshotMirrorStatus).toHaveBeenCalledWith('test-fs'); + expect(component.mirrorPaths.length).toBe(2); + }); + + it('should set empty array on error', () => { + cephfsService.getSnapshotMirrorStatus.mockReturnValue( + throwError(() => new Error('API Error')) + ); + component.fsName = 'test-fs'; + + component.loadMirrorPaths(); + + expect(component.mirrorPaths).toEqual([]); + }); + + it('should not call service when fsName is empty', () => { + component.fsName = ''; + + component.loadMirrorPaths(); + + expect(cephfsService.getSnapshotMirrorStatus).not.toHaveBeenCalled(); + }); + }); + + describe('getSyncStatusIcon', () => { + it('should return correct icon for syncing status', () => { + const icon = component.getSyncStatusIcon('syncing'); + expect(icon).toBe('inProgress'); + }); + + it('should return correct icon for idle status', () => { + const icon = component.getSyncStatusIcon('idle'); + expect(icon).toBe('pendingFilled'); + }); + + it('should return correct icon for failed status', () => { + const icon = component.getSyncStatusIcon('failed'); + expect(icon).toBe('danger'); + }); + + it('should return correct icon for completed status', () => { + const icon = component.getSyncStatusIcon('completed'); + expect(icon).toBe('checkMarkOutline'); + }); + + it('should return default icon for unknown status', () => { + const icon = component.getSyncStatusIcon('unknown'); + expect(icon).toBe('infoCircle'); + }); + }); + + describe('getSyncStatusClass', () => { + it('should return correct class for syncing status', () => { + const cssClass = component.getSyncStatusClass('syncing'); + expect(cssClass).toBe('info'); + }); + + it('should return correct class for completed status', () => { + const cssClass = component.getSyncStatusClass('completed'); + expect(cssClass).toBe('success'); + }); + + it('should return correct class for idle status', () => { + const cssClass = component.getSyncStatusClass('idle'); + expect(cssClass).toBe('muted'); + }); + + it('should return correct class for failed status', () => { + const cssClass = component.getSyncStatusClass('failed'); + expect(cssClass).toBe('danger'); + }); + + it('should return empty string for unknown status', () => { + const cssClass = component.getSyncStatusClass('unknown'); + expect(cssClass).toBe(''); + }); + }); + + describe('onPathClick', () => { + it('should set selected path and open side panel', () => { + const mockPath = { + path: '/test', + syncStatus: 'syncing' as const, + currentSyncSnapshot: 'snap1', + lastSyncedSnapshot: 'snap0' + }; + + component.onPathClick(mockPath as any); + + expect(component.selectedPath).toBe(mockPath); + expect(component.sidePanelOpen).toBe(true); + }); + }); + + describe('closeSidePanel', () => { + it('should close side panel and clear selected path', () => { + component.sidePanelOpen = true; + component.selectedPath = {} as any; + + component.closeSidePanel(); + + expect(component.sidePanelOpen).toBe(false); + expect(component.selectedPath).toBeNull(); + }); + }); + + describe('onSelectionChange', () => { + it('should update selection', () => { + const mockSelection = { selected: [{ path: '/test' }] } as any; + + component.selection = mockSelection; + + expect(component.selection).toBe(mockSelection); + }); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts index 69730037ede..991d663c7c1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts @@ -8,18 +8,86 @@ import { } from '@angular/core'; import { ActivatedRoute } from '@angular/router'; import { Subscription } from 'rxjs'; -import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { CephfsService, SnapshotMirrorStatusResponse } from '~/app/shared/api/cephfs.service'; import { CdTableColumn } from '~/app/shared/models/cd-table-column'; import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; -import { Icons } from '~/app/shared/enum/icons.enum'; +import { ICON_TYPE } from '~/app/shared/enum/icons.enum'; +import { FormatterService } from '~/app/shared/services/formatter.service'; interface MirrorPath { path: string; syncStatus: 'syncing' | 'idle' | 'failed'; currentSyncSnapshot: string; currentSyncEta?: string; + currentSyncMode?: string; lastSyncedSnapshot: string; lastSyncedTime?: string; + snapshotCount?: number; + checkpointCount?: number; + renamedSnapshotCount?: number; + syncProgress?: number; + filesSynced?: number; + totalFiles?: number; + bytesSynced?: number; + totalBytes?: number; + crawlState?: string; + crawlDuration?: string; + datasyncQueueWaitState?: string; + datasyncQueueWaitDuration?: string; + avgReadThroughput?: string; + avgWriteThroughput?: string; +} + +interface PathData { + peer: Record; +} + +interface PeerInfo { + state: string; + current_sync_snap?: CurrentSyncSnap; + current_syncing_snap?: CurrentSyncSnap; + last_synced_snap?: LastSyncedSnap; + snaps_synced?: number; + snaps_deleted?: number; + snaps_renamed?: number; +} + +interface CurrentSyncSnap { + id?: number; + name: string; + 'sync-mode'?: string; + avg_read_throughput_bytes?: string; + avg_write_throughput_bytes?: string; + crawl?: { + state?: string; + duration?: string; + }; + datasync_queue_wait?: { + state?: string; + duration?: string; + }; + bytes?: { + sync_bytes?: string; + total_bytes?: string; + sync_percent?: string; + }; + files?: { + sync_files?: number; + total_files?: number; + sync_percent?: string; + }; + eta?: string; +} + +interface LastSyncedSnap { + id?: number; + name: string; + crawl_duration?: string; + datasync_queue_wait_duration?: string; + sync_duration?: string; + sync_time_stamp?: string; + sync_bytes?: string; + sync_files?: number; } @Component({ @@ -31,28 +99,32 @@ interface MirrorPath { }) export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy { @ViewChild('syncStatusTpl', { static: true }) - syncStatusTpl!: TemplateRef; + syncStatusTpl!: TemplateRef; @ViewChild('pathTpl', { static: true }) - pathTpl!: TemplateRef; + pathTpl!: TemplateRef; @ViewChild('currentSyncSnapshotTpl', { static: true }) - currentSyncSnapshotTpl!: TemplateRef; + currentSyncSnapshotTpl!: TemplateRef; columns: CdTableColumn[] = []; mirrorPaths: MirrorPath[] = []; selection = new CdTableSelection(); selectedPath: MirrorPath | null = null; sidePanelOpen = false; - icons = Icons; fsName: string = ''; private subscriptions = new Subscription(); - constructor(private cephfsService: CephfsService, private route: ActivatedRoute) {} + constructor( + private cephfsService: CephfsService, + private route: ActivatedRoute, + private formatterService: FormatterService + ) {} ngOnInit(): void { this.initializeColumns(); + this.fetchFsName(); } ngOnDestroy(): void { @@ -75,6 +147,11 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy cellTemplate: this.syncStatusTpl, sortable: true }, + { + name: $localize`Snapshots synced`, + prop: 'snapshotCount', + flexGrow: 1.5 + }, { name: $localize`Current sync snapshot`, prop: 'currentSyncSnapshot', @@ -91,7 +168,7 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy ]; } - fetchFsName(): void { + private fetchFsName(): void { this.subscriptions.add( this.route.parent?.paramMap.subscribe((paramMap) => { this.fsName = paramMap.get('fsName') || ''; @@ -107,59 +184,127 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy return; } - this.subscriptions.add( - this.cephfsService.getSnapshotMirrorStatus(this.fsName).subscribe( - (data: any) => { - this.mirrorPaths = this.parseMirrorStatus(data); - }, - (_) => { - this.mirrorPaths = []; + this.cephfsService.getSnapshotMirrorStatus(this.fsName).subscribe( + (data: SnapshotMirrorStatusResponse) => { + this.mirrorPaths = this.parseMirrorStatus(data); + if (this.selectedPath) { + this.selectedPath = + this.mirrorPaths.find((mirrorPath) => mirrorPath.path === this.selectedPath?.path) ?? + null; + this.sidePanelOpen = !!this.selectedPath; } - ) + }, + (_) => { + this.mirrorPaths = []; + this.selectedPath = null; + this.sidePanelOpen = false; + } ); } - parseMirrorStatus(data: any): MirrorPath[] { + parseMirrorStatus(data: SnapshotMirrorStatusResponse): MirrorPath[] { + if (!data?.metrics) { + return []; + } + const paths: MirrorPath[] = []; - if (!data || !data.metrics) { - return paths; - } + for (const path in data.metrics) { + if (Object.prototype.hasOwnProperty.call(data.metrics, path)) { + const pathData = data.metrics[path] as PathData; - for (const [path, pathData] of Object.entries(data.metrics)) { - if (pathData && typeof pathData === 'object' && 'peer' in pathData) { - const peerData: any = pathData; - - const peerEntries = Object.entries(peerData.peer || {}); - if (peerEntries.length > 0) { - const [, peerInfo]: [string, any] = peerEntries[0]; - - const syncStatus = peerInfo.state || 'idle'; - const lastSyncedSnap = peerInfo.last_synced_snap?.name || '-'; - const currentSyncSnap = peerInfo.current_sync_snap?.name || '-'; - let currentSyncEta: string | undefined; - if (peerInfo.current_sync_snap?.eta_completion) { - currentSyncEta = peerInfo.current_sync_snap.eta_completion; - } - - const mirrorPath: MirrorPath = { - path: path, - syncStatus: syncStatus, - currentSyncSnapshot: currentSyncSnap, - currentSyncEta: currentSyncEta, - lastSyncedSnapshot: lastSyncedSnap - }; - - paths.push(mirrorPath); - } + // Skip invalid entries + if (!pathData?.peer) continue; + + const peerInfo = this.extractPeerInfo(pathData); + if (!peerInfo) continue; + + paths.push(this.buildMirrorPath(path, peerInfo)); } } return paths; } - onSelectionChange(selection: CdTableSelection): void { - this.selection = selection; + private parsePercent(percent: string | undefined): number { + if (!percent) { + return 0; + } + + const match = percent.match(/([\d.]+)%/); + return match ? Math.round(parseFloat(match[1])) : 0; + } + + private parseByteValue(value: string | undefined): number { + if (!value) { + return 0; + } + + const normalizedValue = value.replace(/\s+/g, ''); + return this.formatterService.toBytes(normalizedValue, 0) ?? 0; + } + + private calculateSyncProgress( + fileProgress: number, + filesSynced: number, + totalFiles: number, + bytesSynced: number, + totalBytes: number, + byteProgress: number + ): number { + if (fileProgress > 0) return fileProgress; + if (totalFiles > 0) return Math.round((filesSynced / totalFiles) * 100); + if (byteProgress > 0) return byteProgress; + if (totalBytes > 0) return Math.round((bytesSynced / totalBytes) * 100); + return 0; + } + + private extractPeerInfo(pathData: PathData): PeerInfo | null { + const peerEntries = Object.entries(pathData.peer ?? {}); + return peerEntries.length > 0 ? peerEntries[0][1] : null; + } + + private buildMirrorPath(path: string, peerInfo: PeerInfo): MirrorPath { + const currentSnap = peerInfo.current_syncing_snap ?? peerInfo.current_sync_snap; + const filesSynced = currentSnap?.files?.sync_files ?? 0; + const totalFiles = currentSnap?.files?.total_files ?? 0; + const fileProgress = this.parsePercent(currentSnap?.files?.sync_percent); + const bytesSynced = this.parseByteValue(currentSnap?.bytes?.sync_bytes); + const totalBytes = this.parseByteValue(currentSnap?.bytes?.total_bytes); + const byteProgress = this.parsePercent(currentSnap?.bytes?.sync_percent); + + const syncProgress = this.calculateSyncProgress( + fileProgress, + filesSynced, + totalFiles, + bytesSynced, + totalBytes, + byteProgress + ); + + return { + path, + syncStatus: (peerInfo.state ?? 'idle') as 'syncing' | 'idle' | 'failed', + currentSyncSnapshot: currentSnap?.name ?? '-', + currentSyncEta: currentSnap?.eta, + currentSyncMode: currentSnap?.['sync-mode'], + lastSyncedSnapshot: peerInfo.last_synced_snap?.name ?? '-', + lastSyncedTime: peerInfo.last_synced_snap?.sync_time_stamp, + snapshotCount: peerInfo.snaps_synced ?? 0, + checkpointCount: peerInfo.snaps_deleted ?? 0, + renamedSnapshotCount: peerInfo.snaps_renamed ?? 0, + syncProgress, + filesSynced, + totalFiles, + bytesSynced, + totalBytes, + crawlState: currentSnap?.crawl?.state, + crawlDuration: currentSnap?.crawl?.duration, + datasyncQueueWaitState: currentSnap?.datasync_queue_wait?.state, + datasyncQueueWaitDuration: currentSnap?.datasync_queue_wait?.duration, + avgReadThroughput: currentSnap?.avg_read_throughput_bytes, + avgWriteThroughput: currentSnap?.avg_write_throughput_bytes + }; } onPathClick(path: MirrorPath): void { @@ -172,29 +317,31 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy this.selectedPath = null; } - getSyncStatusIcon(status: string): string { + getSyncStatusIcon(status: string): keyof typeof ICON_TYPE { switch (status) { case 'syncing': - return Icons.inProgress; + return 'inProgress'; case 'idle': - return Icons.pendingFilled; + return 'pendingFilled'; case 'failed': - return Icons.danger; + return 'danger'; + case 'completed': + return 'checkMarkOutline'; default: - return Icons.circle; + return 'infoCircle'; } } getSyncStatusClass(status: string): string { switch (status) { case 'syncing': - return 'text-info'; + return 'info'; case 'completed': - return 'text-success'; + return 'success'; case 'idle': - return 'text-muted'; + return 'muted'; case 'failed': - return 'text-danger'; + return 'danger'; default: return ''; } 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 c42edba7e06..9d63b627ccd 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 @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { NgModule } from '@angular/core'; +import { CUSTOM_ELEMENTS_SCHEMA, NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { @@ -64,7 +64,8 @@ import { TilesModule, TreeviewModule, TabsModule, - NotificationModule + NotificationModule, + ProgressBarModule } from 'carbon-components-angular'; import AddIcon from '@carbon/icons/es/add/32'; @@ -77,6 +78,7 @@ import ReplicateIcon24 from '@carbon/icons/es/replicate/24'; 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'; @NgModule({ imports: [ @@ -115,7 +117,8 @@ import PendingFilled from '@carbon/icons/es/pending--filled/16'; RadioModule, TilesModule, TagModule, - NotificationModule + NotificationModule, + ProgressBarModule ], declarations: [ CephfsDetailComponent, @@ -147,7 +150,8 @@ import PendingFilled from '@carbon/icons/es/pending--filled/16'; CephfsSetupMirroringComponent, CephfsAddMirroringPathComponent ], - providers: [provideCharts(withDefaultRegisterables())] + providers: [provideCharts(withDefaultRegisterables())], + schemas: [CUSTOM_ELEMENTS_SCHEMA] }) export class CephfsModule { constructor(private iconService: IconService) { @@ -161,7 +165,8 @@ export class CephfsModule { ReplicateIcon24, ShareIcon, ShareIcon24, - PendingFilled + PendingFilled, + DotMark ]); } } 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 616811e4476..733ece46817 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 @@ -9,6 +9,67 @@ import { CephfsDir, CephfsQuotas } from '../models/cephfs-directory-models'; import { shareReplay } from 'rxjs/operators'; import { Daemon } from '../models/cephfs.model'; +export interface SnapshotMirrorStatusResponse { + metrics: { + [path: string]: { + peer: { + [peerId: string]: { + state: string; + current_sync_snap?: { + name: string; + files?: string | number; + bytes?: string | number; + eta_completion?: string; + files_synced?: number; + total_files?: number; + bytes_synced?: number; + total_bytes?: number; + }; + current_syncing_snap?: { + id?: number; + name: string; + 'sync-mode'?: string; + avg_read_throughput_bytes?: string; + avg_write_throughput_bytes?: string; + crawl?: { + state?: string; + duration?: string; + }; + datasync_queue_wait?: { + state?: string; + duration?: string; + }; + bytes?: { + sync_bytes?: string; + total_bytes?: string; + sync_percent?: string; + }; + files?: { + sync_files?: number; + total_files?: number; + sync_percent?: string; + }; + eta?: string; + }; + last_synced_snap?: { + id: number; + name: string; + crawl_duration?: string; + datasync_queue_wait_duration?: string; + sync_duration?: string; + sync_time_stamp?: string; + sync_bytes?: string; + sync_files?: number; + }; + snaps_synced?: number; + snaps_deleted?: number; + snaps_renamed?: number; + }; + }; + }; + }; +} + @cdEncode @Injectable({ providedIn: 'root' @@ -157,7 +218,7 @@ export class CephfsService { fsName: string, mirroredDirPath?: string, peerUuid?: string - ): Observable { + ): Observable { let params = new HttpParams(); if (mirroredDirPath) { params = params.append('mirrored_dir_path', mirroredDirPath); @@ -165,6 +226,9 @@ export class CephfsService { if (peerUuid) { params = params.append('peer_uuid', peerUuid); } - return this.http.get(`${this.baseURL}/mirror/snapshot-mirror-status/${fsName}`, { params }); + return this.http.get( + `${this.baseURL}/mirror/snapshot-mirror-status/${fsName}`, + { params } + ); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index ebfc501f584..d26ad5731e3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -210,9 +210,9 @@ input:-webkit-autofill:active { background-color: colors.$gray-30; } -.cds--progress-bar__bar { - background-color: var(--cds-primary); -} +// .cds--progress-bar__bar { +// background-color: var(--cds-primary); +// } // Carbon borders .border-subtle { border: 1px solid var(--cds-border-subtle); diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss index ef04f9b6fa7..2795bfa0552 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss @@ -5,6 +5,10 @@ padding: 0; } +.cds-p-4 { + padding: layout.$spacing-04; +} + .cds-pl-6 { padding-left: layout.$spacing-06; } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index b466fb12a42..eebb19f0f6d 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -3373,6 +3373,47 @@ paths: summary: Enable mirroring for a filesystem tags: - CephfsMirror + /api/cephfs/mirror/snapshot-mirror-status/{fs_name}: + get: + parameters: + - in: path + name: fs_name + required: true + schema: + type: string + - allowEmptyValue: true + in: query + name: mirrored_dir_path + schema: + type: string + - allowEmptyValue: true + in: query + name: peer_uuid + 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: [] + tags: + - CephfsMirror /api/cephfs/mirror/token: post: parameters: []