]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add Details tab -> Mirror path Listing
authorAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Wed, 24 Jun 2026 10:02:46 +0000 (15:32 +0530)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 05:31:34 +0000 (07:31 +0200)
Signed-off-by: Aashish Sharma <aasharma@redhat.com>
src/pybind/mgr/dashboard/controllers/cephfs.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.scss
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts
src/pybind/mgr/dashboard/frontend/src/styles.scss
src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_spacings.scss
src/pybind/mgr/dashboard/openapi.yaml

index 62f994b81fcceab42e4957bc1f9adfde9c494060..5d30c1cd7abb4f84b93c1ed1b271d334f784fac7 100644 (file)
@@ -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}',
index f1279f35f498265326dd6499c522b411ca9b3137..1549bf945cc65091519d1146e45cfd78849dc9c4 100644 (file)
-<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>
index 339a8fcc78cd321b5d8974c89cbe32b2abdae973..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 100644 (file)
@@ -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 (file)
index 0000000..9670380
--- /dev/null
@@ -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<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);
+    });
+  });
+});
index 69730037edeaea5465d4c8d2c300c1f431d52649..991d663c7c19317911782c8eebc57cc1382e6332 100644 (file)
@@ -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<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({
@@ -31,28 +99,32 @@ interface MirrorPath {
 })
 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 {
@@ -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 '';
     }
index c42edba7e0681aafcdfe805bc1424832f5d2cf28..9d63b627ccde508c08d2fcd7c80c85312ca7c336 100644 (file)
@@ -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
     ]);
   }
 }
index 616811e447646408095413d7fe24a3557bcf98db..733ece46817e211ae0a4e3bb168744a9234431a3 100644 (file)
@@ -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<any> {
+  ): Observable<SnapshotMirrorStatusResponse> {
     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<SnapshotMirrorStatusResponse>(
+      `${this.baseURL}/mirror/snapshot-mirror-status/${fsName}`,
+      { params }
+    );
   }
 }
index ebfc501f5842b3cbe96722cd9b84ec47fc1b3301..d26ad5731e328c08f111cc584f94c8c0c7bf5d8e 100644 (file)
@@ -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);
index ef04f9b6fa73df968d243938dc93967e803eabfe..2795bfa055291850e8348b7a072bd5164ed58967 100644 (file)
@@ -5,6 +5,10 @@
   padding: 0;
 }
 
+.cds-p-4 {
+  padding: layout.$spacing-04;
+}
+
 .cds-pl-6 {
   padding-left: layout.$spacing-06;
 }
index b466fb12a42d55d2c2aadc02f4db02e6cba22f1a..eebb19f0f6d5de2b8bf449ee85493ff044ffa030 100644 (file)
@@ -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: []