]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add cephfs mirror path listing
authorAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Tue, 23 Jun 2026 12:30:19 +0000 (18:00 +0530)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 05:31:17 +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.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/app/shared/enum/icons.enum.ts

index f8cfcd0e1d3610b9433255c3012464ef6c93a43f..62f994b81fcceab42e4957bc1f9adfde9c494060 100644 (file)
@@ -1409,6 +1409,18 @@ class CephFSMirror(RESTController):
             )
         return json.loads(out)
 
+    @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)
+        if error_code != 0:
+            raise DashboardException(
+                msg=f'Failed to get Cephfs snapshot mirror status: {err}',
+                code=error_code,
+                component='cephfs.mirror'
+            )
+        return json.loads(out)
+
 
 @UIRouter('/cephfs/mirror')
 class CephFSMirrorStatus(RESTController):
index f7d6e4f3b4bd9def29ac90f58dc3af4b965cc264..f1279f35f498265326dd6499c522b411ca9b3137 100644 (file)
@@ -1,6 +1,57 @@
-<main class="cephfs-mirroring-fs-mirror-paths">
-  <cds-tile>
-    <p class="cds--type-body-01"
-       i18n>Mirror paths content will be available here.</p>
-  </cds-tile>
-</main>
+<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>
+          </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 }}
+            </div>
+          </div>
+        </ng-template>
+      </cd-table>
+    </div>
+
+    <cd-side-panel
+      *ngIf="selectedPath"
+      [expanded]="sidePanelOpen"
+      [headerText]="selectedPath.path"
+      size="xl"
+      (closed)="closeSidePanel()">
+    </cd-side-panel>
+</div>
index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..339a8fcc78cd321b5d8974c89cbe32b2abdae973 100644 (file)
@@ -0,0 +1,29 @@
+.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
index b0693024a779e2661e1aa95170353f88041ed3a5..69730037edeaea5465d4c8d2c300c1f431d52649 100644 (file)
@@ -1,4 +1,26 @@
-import { Component, ViewEncapsulation } from '@angular/core';
+import {
+  Component,
+  OnInit,
+  OnDestroy,
+  TemplateRef,
+  ViewChild,
+  ViewEncapsulation
+} from '@angular/core';
+import { ActivatedRoute } from '@angular/router';
+import { Subscription } from 'rxjs';
+import { CephfsService } 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';
+
+interface MirrorPath {
+  path: string;
+  syncStatus: 'syncing' | 'idle' | 'failed';
+  currentSyncSnapshot: string;
+  currentSyncEta?: string;
+  lastSyncedSnapshot: string;
+  lastSyncedTime?: string;
+}
 
 @Component({
   selector: 'cd-cephfs-mirroring-fs-mirror-paths',
@@ -7,4 +29,174 @@ import { Component, ViewEncapsulation } from '@angular/core';
   standalone: false,
   encapsulation: ViewEncapsulation.None
 })
-export class CephfsMirroringFsMirrorPathsComponent {}
+export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy {
+  @ViewChild('syncStatusTpl', { static: true })
+  syncStatusTpl!: TemplateRef<any>;
+
+  @ViewChild('pathTpl', { static: true })
+  pathTpl!: TemplateRef<any>;
+
+  @ViewChild('currentSyncSnapshotTpl', { static: true })
+  currentSyncSnapshotTpl!: TemplateRef<any>;
+
+  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) {}
+
+  ngOnInit(): void {
+    this.initializeColumns();
+  }
+
+  ngOnDestroy(): void {
+    this.subscriptions.unsubscribe();
+  }
+
+  initializeColumns(): void {
+    this.columns = [
+      {
+        name: $localize`Path`,
+        prop: 'path',
+        flexGrow: 2,
+        cellTemplate: this.pathTpl,
+        sortable: true
+      },
+      {
+        name: $localize`Sync status`,
+        prop: 'syncStatus',
+        flexGrow: 1.5,
+        cellTemplate: this.syncStatusTpl,
+        sortable: true
+      },
+      {
+        name: $localize`Current sync snapshot`,
+        prop: 'currentSyncSnapshot',
+        flexGrow: 1.5,
+        cellTemplate: this.currentSyncSnapshotTpl,
+        sortable: true
+      },
+      {
+        name: $localize`Last synced snapshot`,
+        prop: 'lastSyncedSnapshot',
+        flexGrow: 1.5,
+        sortable: true
+      }
+    ];
+  }
+
+  fetchFsName(): void {
+    this.subscriptions.add(
+      this.route.parent?.paramMap.subscribe((paramMap) => {
+        this.fsName = paramMap.get('fsName') || '';
+        if (this.fsName) {
+          this.loadMirrorPaths();
+        }
+      }) || new Subscription()
+    );
+  }
+
+  loadMirrorPaths(): void {
+    if (!this.fsName) {
+      return;
+    }
+
+    this.subscriptions.add(
+      this.cephfsService.getSnapshotMirrorStatus(this.fsName).subscribe(
+        (data: any) => {
+          this.mirrorPaths = this.parseMirrorStatus(data);
+        },
+        (_) => {
+          this.mirrorPaths = [];
+        }
+      )
+    );
+  }
+
+  parseMirrorStatus(data: any): MirrorPath[] {
+    const paths: MirrorPath[] = [];
+
+    if (!data || !data.metrics) {
+      return paths;
+    }
+
+    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);
+        }
+      }
+    }
+
+    return paths;
+  }
+
+  onSelectionChange(selection: CdTableSelection): void {
+    this.selection = selection;
+  }
+
+  onPathClick(path: MirrorPath): void {
+    this.selectedPath = path;
+    this.sidePanelOpen = true;
+  }
+
+  closeSidePanel(): void {
+    this.sidePanelOpen = false;
+    this.selectedPath = null;
+  }
+
+  getSyncStatusIcon(status: string): string {
+    switch (status) {
+      case 'syncing':
+        return Icons.inProgress;
+      case 'idle':
+        return Icons.pendingFilled;
+      case 'failed':
+        return Icons.danger;
+      default:
+        return Icons.circle;
+    }
+  }
+
+  getSyncStatusClass(status: string): string {
+    switch (status) {
+      case 'syncing':
+        return 'text-info';
+      case 'completed':
+        return 'text-success';
+      case 'idle':
+        return 'text-muted';
+      case 'failed':
+        return 'text-danger';
+      default:
+        return '';
+    }
+  }
+}
index 219662364db67dfc5f8021158ab542a97abc2a27..6a65257472f4816af98721ff41e2f1301cba3b44 100644 (file)
@@ -75,6 +75,7 @@ import ReplicateIcon from '@carbon/icons/es/replicate/32';
 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';
 
 @NgModule({
   imports: [
@@ -157,7 +158,8 @@ export class CephfsModule {
       ReplicateIcon,
       ReplicateIcon24,
       ShareIcon,
-      ShareIcon24
+      ShareIcon24,
+      PendingFilled
     ]);
   }
 }
index 15bab5f3e42711a30a6bf92e932cc25a86a5341a..616811e447646408095413d7fe24a3557bcf98db 100644 (file)
@@ -152,4 +152,19 @@ export class CephfsService {
       token: token
     });
   }
+
+  getSnapshotMirrorStatus(
+    fsName: string,
+    mirroredDirPath?: string,
+    peerUuid?: string
+  ): Observable<any> {
+    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 });
+  }
 }
index 5a304fc43ac1e4380df0049ed6a9ae14d21fd33a..53076bf8a3a4c8478fc691a95672c7f2e9f7e686 100644 (file)
@@ -125,7 +125,8 @@ export enum Icons {
   inProgress = 'in-progress',
   arrowDown = 'arrow--down',
   locked = 'locked', // Access denied, locked state
-  cloudMonitoring = 'cloud--monitoring'
+  cloudMonitoring = 'cloud--monitoring',
+  pendingFilled = 'pending--filled'
 }
 
 export enum IconSize {
@@ -176,7 +177,10 @@ export const ICON_TYPE = {
   rightArrow: 'caret--right',
   locked: 'locked',
   cloudMonitoring: 'cloud--monitoring',
-  trash: 'trash-can'
+  trash: 'trash-can',
+  replicate: 'replicate',
+  share: 'share',
+  pendingFilled: 'pending--filled'
 } as const;
 
 export const EMPTY_STATE_IMAGE = {