@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}',
-<div class="cephfs-mirroring-fs-mirror-paths">
- <span class="cds--type-heading-03 cds-mt-6">Mirror paths</span>
- <div class="cds-mt-6">
- <cd-table
- [data]="mirrorPaths"
- [columns]="columns"
- columnMode="flex"
- selectionType="single"
- [hasDetails]="false"
- (updateSelection)="onSelectionChange($event)"
- [toolHeader]="true"
- [searchableObjects]="true"
- (fetchData)="fetchFsName()">
-
- <ng-template
- #pathTpl
- let-row="data.row">
- <a href="javascript:void(0)"
- (click)="onPathClick(row)"
- class="path-link">
- {{ row.path }}
- </a>
- </ng-template>
-
- <ng-template
- #syncStatusTpl
- let-row="data.row">
- <div class="d-flex align-items-center">
- <svg
- [cdsIcon]="getSyncStatusIcon(row.syncStatus)"
- [size]="icons.size16"
- [ngClass]="['me-2', getSyncStatusClass(row.syncStatus)]"></svg>
- <span>{{ row.syncStatus | titlecase }}</span>
+<span class="cds--type-heading-03 cds-mt-6">Mirror paths</span>
+<div class="cds-mt-6">
+ <cd-table
+ [data]="mirrorPaths"
+ [columns]="columns"
+ columnMode="flex"
+ [hasDetails]="false"
+ [autoReload]="5000"
+ [toolHeader]="true"
+ [searchableObjects]="true"
+ (fetchData)="loadMirrorPaths()">
+
+ <ng-template
+ #pathTpl
+ let-row="data.row">
+ <a href="javascript:void(0)"
+ (click)="onPathClick(row)"
+ class="cds--link">
+ {{ row.path }}
+ </a>
+ </ng-template>
+
+ <ng-template
+ #syncStatusTpl
+ let-row="data.row">
+ <div class="d-flex align-items-center">
+ <cd-icon
+ [type]="getSyncStatusIcon(row.syncStatus)"
+ [ngClass]="['cds-mr-2', getSyncStatusClass(row.syncStatus)]">
+ </cd-icon>
+ <span>{{ row.syncStatus | titlecase }}</span>
+ </div>
+ </ng-template>
+
+ <ng-template
+ #currentSyncSnapshotTpl
+ let-row="data.row">
+ <div>
+ <div>{{ row.currentSyncSnapshot }}</div>
+ <div *ngIf="row.currentSyncEta" class="cds--type-helper-text-01">
+ Time remaining: {{ row.currentSyncEta }}
+ </div>
+ </div>
+ </ng-template>
+ </cd-table>
+</div>
+
+<cd-side-panel
+ *ngIf="selectedPath"
+ [expanded]="sidePanelOpen"
+ [headerText]="selectedPath.path"
+ size="xl"
+ (closed)="closeSidePanel()">
+ <div class="panel-content">
+ <div class="side-panel-content">
+ <cds-tabs type="line">
+ <cds-tab heading="Details"
+ i18n-heading>
+ <div class="cds-p-4">
+ <!-- Replication status section -->
+ <div class="cds-mb-6">
+ <h5 class="cds--type-heading-compact-01 cds-mb-4"
+ i18n>Replication status</h5>
+
+ <div class="cds-mb-4">
+ <div class="cds--type-label-01 cds-mb-2"
+ i18n>Sync status</div>
+ <div class="d-flex align-items-center">
+ <cd-icon
+ [type]="getSyncStatusIcon(selectedPath.syncStatus)"
+ [ngClass]="['cds-mr-2', getSyncStatusClass(selectedPath.syncStatus)]">
+ </cd-icon>
+ <span class="cds--type-body-01">{{ selectedPath.syncStatus | titlecase }}</span>
+ </div>
+ </div>
+
+ <div class="cds-mb-4">
+ <div class="cds--type-label-01 cds-mb-2"
+ i18n>Current snapshot</div>
+ <div class="cds--type-body-01">{{ selectedPath.currentSyncSnapshot }}</div>
+ </div>
+
+ <div class="cds-mb-4">
+ <div class="cds--type-label-01 cds-mb-2"
+ i18n>Last replicated snapshot</div>
+ <div class="cds--type-body-01">{{ selectedPath.lastSyncedSnapshot }}</div>
+ </div>
</div>
- </ng-template>
-
- <ng-template
- #currentSyncSnapshotTpl
- let-row="data.row">
- <div>
- <div>{{ row.currentSyncSnapshot }}</div>
- <div *ngIf="row.currentSyncEta" class="cds--type-helper-text-01">
- ETA: {{ row.currentSyncEta }}
+
+ <!-- Current snapshot progress section -->
+ <div class="cds-mb-6"
+ *ngIf="selectedPath.syncStatus === 'syncing' && selectedPath.syncProgress !== undefined">
+ <hr>
+
+ <h5 class="cds--type-heading-compact-01 cds-mb-4"
+ i18n>Current snapshot progress</h5>
+
+ <div class="cds-mb-4">
+ <div class="cds--type-body-01 cds-mb-2">
+ <strong>{{ selectedPath.syncProgress }}% synced</strong>
+ </div>
+ <cds-progress-bar
+ [max]="100"
+ [value]="selectedPath.syncProgress || 0"
+ status="active"
+ type="inline"
+ size="small">
+ </cds-progress-bar>
+ </div>
+
+ <div class="cds-mb-4"
+ *ngIf="selectedPath.filesSynced !== undefined && selectedPath.totalFiles !== undefined">
+ <div class="cds--type-label-01 cds-mb-2"
+ i18n>Files Synced</div>
+ <div class="cds--type-body-01">{{ selectedPath.filesSynced }}/{{ selectedPath.totalFiles }}</div>
+ </div>
+
+ <div class="cds-mb-4"
+ *ngIf="selectedPath.bytesSynced !== undefined && selectedPath.totalBytes !== undefined">
+ <div class="cds--type-label-01 cds-mb-2"
+ i18n>Bytes Synced</div>
+ <div class="cds--type-body-01">{{ selectedPath.bytesSynced | dimlessBinary }}/{{ selectedPath.totalBytes | dimlessBinary }}</div>
+ </div>
+
+ <div class="cds-mb-4"
+ *ngIf="selectedPath.currentSyncEta">
+ <div class="cds--type-label-01 cds-mb-2"
+ i18n>Estimated time remaining</div>
+ <div class="cds--type-body-01">{{ selectedPath.currentSyncEta }}</div>
</div>
</div>
- </ng-template>
- </cd-table>
- </div>
+ </div>
+ </cds-tab>
- <cd-side-panel
- *ngIf="selectedPath"
- [expanded]="sidePanelOpen"
- [headerText]="selectedPath.path"
- size="xl"
- (closed)="closeSidePanel()">
- </cd-side-panel>
-</div>
+ <cds-tab heading="Snapshots"
+ i18n-heading>
+ <div class="cds-p-4">
+ <div class="cds--type-body-01"
+ i18n>
+ Snapshot information will be displayed here.
+ </div>
+ </div>
+ </cds-tab>
+
+ <cds-tab heading="Schedule policy"
+ i18n-heading>
+ <div class="cds-p-4">
+ <div class="cds--type-body-01"
+ i18n>
+ Schedule policy information will be displayed here.
+ </div>
+ </div>
+ </cds-tab>
+ </cds-tabs>
+ </div>
+ </div>
+</cd-side-panel>
-.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
--- /dev/null
+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<CephfsMirroringFsMirrorPathsComponent>;
+ 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);
+ });
+ });
+});
} 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<string, PeerInfo>;
+}
+
+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({
})
export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy {
@ViewChild('syncStatusTpl', { static: true })
- syncStatusTpl!: TemplateRef<any>;
+ syncStatusTpl!: TemplateRef<unknown>;
@ViewChild('pathTpl', { static: true })
- pathTpl!: TemplateRef<any>;
+ pathTpl!: TemplateRef<unknown>;
@ViewChild('currentSyncSnapshotTpl', { static: true })
- currentSyncSnapshotTpl!: TemplateRef<any>;
+ currentSyncSnapshotTpl!: TemplateRef<unknown>;
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 {
cellTemplate: this.syncStatusTpl,
sortable: true
},
+ {
+ name: $localize`Snapshots synced`,
+ prop: 'snapshotCount',
+ flexGrow: 1.5
+ },
{
name: $localize`Current sync snapshot`,
prop: 'currentSyncSnapshot',
];
}
- fetchFsName(): void {
+ private fetchFsName(): void {
this.subscriptions.add(
this.route.parent?.paramMap.subscribe((paramMap) => {
this.fsName = paramMap.get('fsName') || '';
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 {
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 '';
}
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 {
TilesModule,
TreeviewModule,
TabsModule,
- NotificationModule
+ NotificationModule,
+ ProgressBarModule
} from 'carbon-components-angular';
import AddIcon from '@carbon/icons/es/add/32';
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: [
RadioModule,
TilesModule,
TagModule,
- NotificationModule
+ NotificationModule,
+ ProgressBarModule
],
declarations: [
CephfsDetailComponent,
CephfsSetupMirroringComponent,
CephfsAddMirroringPathComponent
],
- providers: [provideCharts(withDefaultRegisterables())]
+ providers: [provideCharts(withDefaultRegisterables())],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class CephfsModule {
constructor(private iconService: IconService) {
ReplicateIcon24,
ShareIcon,
ShareIcon24,
- PendingFilled
+ PendingFilled,
+ DotMark
]);
}
}
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'
fsName: string,
mirroredDirPath?: string,
peerUuid?: string
- ): Observable<any> {
+ ): Observable<SnapshotMirrorStatusResponse> {
let params = new HttpParams();
if (mirroredDirPath) {
params = params.append('mirrored_dir_path', mirroredDirPath);
if (peerUuid) {
params = params.append('peer_uuid', peerUuid);
}
- return this.http.get(`${this.baseURL}/mirror/snapshot-mirror-status/${fsName}`, { params });
+ return this.http.get<SnapshotMirrorStatusResponse>(
+ `${this.baseURL}/mirror/snapshot-mirror-status/${fsName}`,
+ { params }
+ );
}
}
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);
padding: 0;
}
+.cds-p-4 {
+ padding: layout.$spacing-04;
+}
+
.cds-pl-6 {
padding-left: layout.$spacing-06;
}
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: []