]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add schedule policy tab to Mirro paths table -> side dashboard-cephfs-mirror-feature 69859/head
authorAashish Sharma <aashish@li-e9bf2ecc-2ad7-11b2-a85c-baf05c5182ab.ibm.com>
Mon, 29 Jun 2026 13:05:36 +0000 (18:35 +0530)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Wed, 1 Jul 2026 05:51:49 +0000 (07:51 +0200)
panel details

Signed-off-by: Aashish Sharma <aasharma@redhat.com>
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.spec.ts
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/shared/models/cephfs.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/snapshot-schedule.ts

index 1549bf945cc65091519d1146e45cfd78849dc9c4..65a529a2c4e03b1f2ed3a6ec7fb70e94fed4443d 100644 (file)
     <ng-template
       #syncStatusTpl
       let-row="data.row">
-      <div class="d-flex align-items-center">
+      <div class="vertical-align">
         <cd-icon
           [type]="getSyncStatusIcon(row.syncStatus)"
           [ngClass]="['cds-mr-2', getSyncStatusClass(row.syncStatus)]">
         </cd-icon>
-        <span>{{ row.syncStatus | titlecase }}</span>
+        <span class="cds--type-body-01">{{ row.syncStatus | titlecase }}</span>
       </div>
     </ng-template>
 
       let-row="data.row">
       <div>
         <div>{{ row.currentSyncSnapshot }}</div>
-        <div *ngIf="row.currentSyncEta" class="cds--type-helper-text-01">
-          Time remaining: {{ row.currentSyncEta }}
-        </div>
+        @if (row.currentSyncEta) {
+          <div 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>
+@if (selectedPath) {
+  <cd-side-panel
+    [expanded]="sidePanelOpen"
+    [headerText]="selectedPath.path"
+    size="xl"
+    (closed)="closeSidePanel()">
+    <div class="panel-content">
+      <cds-tabs type="line">
+        <cds-tab heading="Details"
+                 i18n-heading>
+          <div class="cds-p-4">
+            <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="vertical-align">
+                  <cd-icon
+                    [type]="selectedPathSyncStatusIcon"
+                    [ngClass]="['cds-mr-2', selectedPathSyncStatusClass]">
+                  </cd-icon>
+                  <span class="cds--type-body-01">{{ selectedPathSyncStatusLabel }}</span>
+                </div>
               </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>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 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>
-          </div>
 
-          <!-- Current snapshot progress section -->
-          <div class="cds-mb-6"
-                *ngIf="selectedPath.syncStatus === 'syncing' && selectedPath.syncProgress !== undefined">
-            <hr>
+            @if (showSelectedPathProgress) {
+              <div class="cds-mb-6">
+                <hr>
 
-            <h5 class="cds--type-heading-compact-01 cds-mb-4"
-                i18n>Current snapshot progress</h5>
+                <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">
+                  <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>
+                @if (selectedPath.filesSynced !== undefined && selectedPath.totalFiles !== undefined) {
+                  <div class="cds-mb-4">
+                    <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>
+                @if (selectedPath.bytesSynced !== undefined && selectedPath.totalBytes !== undefined) {
+                  <div class="cds-mb-4">
+                    <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>
+                @if (selectedPath.currentSyncEta) {
+                  <div class="cds-mb-4">
+                    <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>
+            }
           </div>
-        </div>
-      </cds-tab>
-
-      <cds-tab heading="Snapshots"
-               i18n-heading>
-        <div class="cds-p-4">
-          <div class="cds--type-body-01"
-               i18n>
-            Snapshot information will be displayed here.
+        </cds-tab>
+
+        <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>
-        </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.
+        </cds-tab>
+
+        <cds-tab heading="Schedule policy"
+                 i18n-heading>
+          <div class="cds-p-4">
+            <span class="cds--type-heading-compact-01 cds-mb-6"
+                  i18n>Total policies applied to path:</span>
+            <span class="cds-mb-6">{{ schedulePolicies.length }}</span>
+
+            @if (schedulePoliciesLoading) {
+              <cds-inline-loading
+                [isActive]="true"
+                loadingText="Loading schedule policies"
+                i18n-loadingText>
+              </cds-inline-loading>
+            } @else {
+              @if (!schedulePolicies.length) {
+                <div class="cds-mt-6">
+                  <span class="cds--type-body-01"
+                        i18n>
+                    No schedule policies applied to this path.
+                  </span>
+                </div>
+              }
+
+              @for (policy of schedulePolicies; track policy.removeId; let last = $last) {
+                <div class="cds-mb-6">
+                  <div class="cds--type-heading-03 cds-mb-5 cds-mt-6">
+                    {{ policy.scheduleCopy || policy.scheduleText }}
+                  </div>
+
+                  <div cdsRow
+                       class="cds-mb-6">
+                    <div class="cds-mb-5 cds--col">
+                      <div class="cds--type-label-01 cds-mb-2"
+                           i18n>Schedule</div>
+                      <div class="cds--type-body-01">{{ policy.scheduleText }}</div>
+                    </div>
+
+                    <div class="cds-mb-5 cds--col">
+                      <div class="cds--type-label-01 cds-mb-2"
+                           i18n>Retention</div>
+                      <div class="cds--type-body-01">{{ policy.retentionText }}</div>
+                    </div>
+
+                    <div class="cds-mb-5 cds--col">
+                      <div class="cds--type-label-01 cds-mb-2"
+                           i18n>Status</div>
+                      <div class="vertical-align">
+                        <cd-icon
+                          [type]="policy.statusIcon || 'warning'"
+                          class='cds-mr-2'>
+                        </cd-icon>
+                        <span class="cds--type-body-01">{{ policy.statusLabel }}</span>
+                      </div>
+                    </div>
+                  </div>
+                  <div cdsRow>
+                    <div class="cds-mb-5 cds--col">
+                      <div class="cds--type-label-01 cds-mb-2"
+                           i18n>Last sync</div>
+                      <div class="cds--type-body-01">{{ formatScheduleDate(policy.last) }}</div>
+                    </div>
+
+                    <div class="cds-mb-5 cds--col">
+                      <div class="cds--type-label-01 cds-mb-2"
+                           i18n>Next sync</div>
+                      <div class="cds--type-body-01">{{ policy.nextSync }}</div>
+                    </div>
+
+                    <div class="cds--col"></div>
+                  </div>
+
+                  <button cdsButton="tertiary"
+                          size="md"
+                          type="button"
+                          [disabled]="removingSchedule === policy.removeId"
+                          (click)="removeSchedulePolicy(policy)">
+                    <span i18n>Remove schedule</span>
+                    <cd-icon type="trash"
+                             class="cds--btn__icon">
+                    </cd-icon>
+                  </button>
+
+                  @if (!last) {
+                    <hr class="cds-mt-6">
+                  }
+                </div>
+              }
+            }
           </div>
-        </div>
-      </cds-tab>
-    </cds-tabs>
+        </cds-tab>
+      </cds-tabs>
     </div>
-  </div>
-</cd-side-panel>
+  </cd-side-panel>
+}
index 0569eba8ef7105a029c4501716015a621bcb6a48..70c6ea373e40be61bf792ad90c0b74b12b7e601e 100644 (file)
@@ -1,12 +1,18 @@
+declare const jest: any;
+
 import { NO_ERRORS_SCHEMA } from '@angular/core';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing';
 import { ActivatedRoute, convertToParamMap } from '@angular/router';
 import { of, throwError } from 'rxjs';
 
-import { CephfsService } from '~/app/shared/api/cephfs.service';
 import { MirrorStatusResponse } from '~/app/shared/models/cephfs.model';
+import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { CephfsMirroringFsMirrorPathsComponent } from './cephfs-mirroring-fs-mirror-paths.component';
+import { HttpClientModule } from '@angular/common/http';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SharedModule } from '~/app/shared/shared.module';
 
 describe('CephfsMirroringFsMirrorPathsComponent', () => {
   let component: CephfsMirroringFsMirrorPathsComponent;
@@ -51,9 +57,7 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
               name: 'snap-last',
               sync_time_stamp: '1583.101609s'
             },
-            snaps_synced: 10,
-            snaps_deleted: 2,
-            snaps_renamed: 1
+            snaps_synced: 10
           }
         }
       },
@@ -80,19 +84,44 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
               id: 2,
               name: 'snap-last-2'
             },
-            snaps_synced: 5,
-            snaps_deleted: 1
+            snaps_synced: 5
           }
         }
       }
     }
   };
 
+  const mockSchedulePolicies = [
+    {
+      path: '/path1',
+      schedule: '1h',
+      start: '2024-01-01T00:00:00Z',
+      retention: { h: 24, d: 7 },
+      active: true,
+      fs: 'test-fs',
+      last: '2024-01-01T01:00:00Z'
+    },
+    {
+      path: '/path1',
+      schedule: '1d',
+      start: '2024-01-01T00:00:00Z',
+      retention: { d: 30 },
+      active: false,
+      fs: 'test-fs'
+    }
+  ];
+
   beforeEach(async () => {
     const cephfsServiceMock = {
       getMirrorStatus: jest.fn()
     };
 
+    const snapshotScheduleServiceMock = {
+      getSnapshotSchedule: jest.fn(),
+      parseScheduleCopy: jest.fn((schedule: string) => schedule),
+      delete: jest.fn()
+    };
+
     const formatterServiceMock = {
       toBytes: jest.fn((value: string) => {
         const match = value.match(/([\d.]+)\s*(\w+)/);
@@ -121,6 +150,10 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
           provide: CephfsService,
           useValue: cephfsServiceMock
         },
+        {
+          provide: CephfsSnapshotScheduleService,
+          useValue: snapshotScheduleServiceMock
+        },
         {
           provide: FormatterService,
           useValue: formatterServiceMock
@@ -134,7 +167,8 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
           }
         }
       ],
-      schemas: [NO_ERRORS_SCHEMA]
+      schemas: [NO_ERRORS_SCHEMA],
+      imports: [HttpClientModule, RouterTestingModule, SharedModule]
     }).compileComponents();
 
     cephfsService = TestBed.inject(CephfsService);
@@ -190,8 +224,6 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
       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);
@@ -207,7 +239,6 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
       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);
@@ -246,7 +277,6 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
       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);
     });
   });
@@ -444,7 +474,7 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
   });
 
   describe('onPathClick', () => {
-    it('should set selected path and open side panel', () => {
+    it('should set selected path and open side panel', fakeAsync(() => {
       const mockPath = {
         path: '/test',
         syncStatus: 'syncing' as const,
@@ -453,10 +483,11 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
       };
 
       component.onPathClick(mockPath as any);
+      tick();
 
       expect(component.selectedPath).toBe(mockPath);
       expect(component.sidePanelOpen).toBe(true);
-    });
+    }));
   });
 
   describe('closeSidePanel', () => {
@@ -480,4 +511,514 @@ describe('CephfsMirroringFsMirrorPathsComponent', () => {
       expect(component.selection).toBe(mockSelection);
     });
   });
+
+  describe('Schedule Policy Tests', () => {
+    let snapshotScheduleService: any;
+
+    beforeEach(() => {
+      snapshotScheduleService = TestBed.inject(CephfsSnapshotScheduleService);
+    });
+
+    describe('loadSchedulePolicies', () => {
+      it('should load schedule policies for a path', () => {
+        snapshotScheduleService.getSnapshotSchedule.mockReturnValue(of(mockSchedulePolicies));
+        component.fsName = 'test-fs';
+        component.selectedPath = { path: '/path1' } as any;
+
+        component.loadSchedulePolicies('/path1');
+
+        expect(snapshotScheduleService.getSnapshotSchedule).toHaveBeenCalledWith(
+          '/path1',
+          'test-fs',
+          false
+        );
+        expect(component.schedulePoliciesLoading).toBe(false);
+        expect(component.schedulePolicies.length).toBeGreaterThan(0);
+      });
+
+      it('should set empty array when fsName is empty', () => {
+        component.fsName = '';
+
+        component.loadSchedulePolicies('/path1');
+
+        expect(snapshotScheduleService.getSnapshotSchedule).not.toHaveBeenCalled();
+        expect(component.schedulePolicies).toEqual([]);
+      });
+
+      it('should set empty array when path is empty', () => {
+        component.fsName = 'test-fs';
+
+        component.loadSchedulePolicies('');
+
+        expect(snapshotScheduleService.getSnapshotSchedule).not.toHaveBeenCalled();
+        expect(component.schedulePolicies).toEqual([]);
+      });
+
+      it('should handle error when loading schedule policies', () => {
+        snapshotScheduleService.getSnapshotSchedule.mockReturnValue(
+          throwError(() => new Error('API Error'))
+        );
+        component.fsName = 'test-fs';
+        component.selectedPath = { path: '/path1' } as any;
+
+        component.loadSchedulePolicies('/path1');
+
+        expect(component.schedulePoliciesLoading).toBe(false);
+        expect(component.schedulePolicies).toEqual([]);
+      });
+
+      it('should filter policies by path', () => {
+        const policies = [
+          ...mockSchedulePolicies,
+          {
+            path: '/path2',
+            schedule: '1h',
+            start: '2024-01-01T00:00:00Z',
+            retention: {},
+            active: true,
+            fs: 'test-fs'
+          }
+        ];
+        snapshotScheduleService.getSnapshotSchedule.mockReturnValue(of(policies));
+        component.fsName = 'test-fs';
+        component.selectedPath = { path: '/path1' } as any;
+
+        component.loadSchedulePolicies('/path1');
+
+        expect(component.schedulePolicies.length).toBe(2);
+        expect(component.schedulePolicies.every((p) => p.path === '/path1')).toBe(true);
+      });
+
+      it('should remove duplicate policies', () => {
+        const duplicatePolicies = [
+          ...mockSchedulePolicies,
+          mockSchedulePolicies[0] // duplicate
+        ];
+        snapshotScheduleService.getSnapshotSchedule.mockReturnValue(of(duplicatePolicies));
+        component.fsName = 'test-fs';
+        component.selectedPath = { path: '/path1' } as any;
+
+        component.loadSchedulePolicies('/path1');
+
+        expect(component.schedulePolicies.length).toBe(2);
+      });
+
+      it('should not update policies if selected path changed', () => {
+        snapshotScheduleService.getSnapshotSchedule.mockReturnValue(of(mockSchedulePolicies));
+        component.fsName = 'test-fs';
+        component.selectedPath = { path: '/path2' } as any;
+
+        component.loadSchedulePolicies('/path1');
+
+        expect(component.schedulePoliciesLoading).toBe(false);
+      });
+    });
+
+    describe('removeSchedulePolicy', () => {
+      it('should remove a schedule policy', () => {
+        snapshotScheduleService.delete.mockReturnValue(of({}));
+        snapshotScheduleService.getSnapshotSchedule.mockReturnValue(of([]));
+        component.fsName = 'test-fs';
+
+        const policy = {
+          path: '/path1',
+          schedule: '1h',
+          start: '2024-01-01T00:00:00Z',
+          fs: 'test-fs'
+        };
+
+        component.removeSchedulePolicy(policy as any);
+
+        expect(snapshotScheduleService.delete).toHaveBeenCalledWith({
+          path: '/path1',
+          schedule: '1h',
+          start: '2024-01-01T00:00:00Z',
+          fs: 'test-fs'
+        });
+        expect(component.removingSchedule).toBe('');
+      });
+
+      it('should handle error when removing schedule policy', () => {
+        snapshotScheduleService.delete.mockReturnValue(throwError(() => new Error('API Error')));
+        component.fsName = 'test-fs';
+
+        const policy = {
+          path: '/path1',
+          schedule: '1h',
+          start: '2024-01-01T00:00:00Z',
+          fs: 'test-fs'
+        };
+
+        component.removeSchedulePolicy(policy as any);
+
+        expect(component.removingSchedule).toBe('');
+      });
+
+      it('should not remove policy when path is missing', () => {
+        component.fsName = 'test-fs';
+
+        const policy = {
+          path: '',
+          schedule: '1h',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        component.removeSchedulePolicy(policy as any);
+
+        expect(snapshotScheduleService.delete).not.toHaveBeenCalled();
+      });
+
+      it('should not remove policy when schedule is missing', () => {
+        component.fsName = 'test-fs';
+
+        const policy = {
+          path: '/path1',
+          schedule: '',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        component.removeSchedulePolicy(policy as any);
+
+        expect(snapshotScheduleService.delete).not.toHaveBeenCalled();
+      });
+
+      it('should not remove policy when fsName is empty', () => {
+        component.fsName = '';
+
+        const policy = {
+          path: '/path1',
+          schedule: '1h',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        component.removeSchedulePolicy(policy as any);
+
+        expect(snapshotScheduleService.delete).not.toHaveBeenCalled();
+      });
+    });
+
+    describe('formatScheduleDate', () => {
+      it('should format valid date string', () => {
+        const result = component.formatScheduleDate('2024-01-01T00:00:00Z');
+        expect(result).not.toBe('-');
+      });
+
+      it('should return dash for null value', () => {
+        const result = component.formatScheduleDate(null);
+        expect(result).toBe('-');
+      });
+
+      it('should return dash for undefined value', () => {
+        const result = component.formatScheduleDate(undefined);
+        expect(result).toBe('-');
+      });
+
+      it('should return dash for invalid date string', () => {
+        const result = component.formatScheduleDate('invalid-date');
+        expect(result).toBe('-');
+      });
+
+      it('should format Date object', () => {
+        const date = new Date('2024-01-01T00:00:00Z');
+        const result = component.formatScheduleDate(date);
+        expect(result).not.toBe('-');
+      });
+    });
+
+    describe('getScheduleStatusIcon', () => {
+      it('should return success icon for active schedule', () => {
+        const icon = component.getScheduleStatusIcon(true);
+        expect(icon).toBe('success');
+      });
+
+      it('should return warning icon for inactive schedule', () => {
+        const icon = component.getScheduleStatusIcon(false);
+        expect(icon).toBe('warning');
+      });
+    });
+
+    describe('buildSchedulePolicyViewModel', () => {
+      it('should build view model with all properties', () => {
+        snapshotScheduleService.parseScheduleCopy.mockReturnValue('Every hour');
+        const policy = mockSchedulePolicies[0];
+
+        const result = component['buildSchedulePolicyViewModel'](policy as any);
+
+        expect(result.scheduleCopy).toBe('Every hour');
+        expect(result.retentionCopy).toBeDefined();
+        expect(result.nextSync).toBeDefined();
+        expect(result.scheduleText).toBe('1h');
+        expect(result.retentionText).toBeDefined();
+        expect(result.statusLabel).toBeDefined();
+        expect(result.statusIcon).toBeDefined();
+        expect(result.removeId).toBe('/path1@1h');
+      });
+
+      it('should handle policy with string retention', () => {
+        snapshotScheduleService.parseScheduleCopy.mockReturnValue('Every hour');
+        const policy = {
+          ...mockSchedulePolicies[0],
+          retention: 'invalid'
+        };
+
+        const result = component['buildSchedulePolicyViewModel'](policy as any);
+
+        expect(result.retention).toEqual({});
+      });
+
+      it('should handle policy without retention', () => {
+        snapshotScheduleService.parseScheduleCopy.mockReturnValue('Every hour');
+        const policy = {
+          path: '/path1',
+          schedule: '1h',
+          start: '2024-01-01T00:00:00Z',
+          active: true,
+          fs: 'test-fs'
+        };
+
+        const result = component['buildSchedulePolicyViewModel'](policy as any);
+
+        expect(result.retentionText).toBe('-');
+      });
+    });
+
+    describe('calculateNextSync', () => {
+      it('should calculate next sync for minutely schedule', () => {
+        const policy = {
+          schedule: '30m',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).not.toBe('-');
+      });
+
+      it('should calculate next sync for hourly schedule', () => {
+        const policy = {
+          schedule: '2h',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).not.toBe('-');
+      });
+
+      it('should calculate next sync for daily schedule', () => {
+        const policy = {
+          schedule: '1d',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).not.toBe('-');
+      });
+
+      it('should calculate next sync for weekly schedule', () => {
+        const policy = {
+          schedule: '1w',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).not.toBe('-');
+      });
+
+      it('should calculate next sync for monthly schedule', () => {
+        const policy = {
+          schedule: '1M',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).not.toBe('-');
+      });
+
+      it('should calculate next sync for yearly schedule', () => {
+        const policy = {
+          schedule: '1y',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).not.toBe('-');
+      });
+
+      it('should use last time if available', () => {
+        const policy = {
+          schedule: '1h',
+          start: '2024-01-01T00:00:00Z',
+          last: '2024-01-01T01:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).not.toBe('-');
+      });
+
+      it('should return dash for missing schedule', () => {
+        const policy = {
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).toBe('-');
+      });
+
+      it('should return dash for missing start time', () => {
+        const policy = {
+          schedule: '1h'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).toBe('-');
+      });
+
+      it('should return dash for invalid date', () => {
+        const policy = {
+          schedule: '1h',
+          start: 'invalid-date'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).toBe('-');
+      });
+
+      it('should return dash for invalid schedule format', () => {
+        const policy = {
+          schedule: 'invalid',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).toBe('-');
+      });
+
+      it('should return dash for unknown schedule unit', () => {
+        const policy = {
+          schedule: '1x',
+          start: '2024-01-01T00:00:00Z'
+        };
+
+        const result = component['calculateNextSync'](policy as any);
+
+        expect(result).toBe('-');
+      });
+    });
+
+    describe('buildRetentionCopy', () => {
+      it('should build retention copy for hourly retention', () => {
+        const retention = { h: 24 };
+
+        const result = component['buildRetentionCopy'](retention);
+
+        expect(result).toContain('24 hourly');
+      });
+
+      it('should build retention copy for daily retention', () => {
+        const retention = { d: 7 };
+
+        const result = component['buildRetentionCopy'](retention);
+
+        expect(result).toContain('7 daily');
+      });
+
+      it('should build retention copy for weekly retention', () => {
+        const retention = { w: 4 };
+
+        const result = component['buildRetentionCopy'](retention);
+
+        expect(result).toContain('4 weekly');
+      });
+
+      it('should build retention copy for monthly retention', () => {
+        const retention = { M: 12 };
+
+        const result = component['buildRetentionCopy'](retention);
+
+        expect(result).toContain('12 monthly');
+      });
+
+      it('should build retention copy for multiple retention periods', () => {
+        const retention = { h: 24, d: 7, w: 4 };
+
+        const result = component['buildRetentionCopy'](retention);
+
+        expect(result.length).toBe(3);
+      });
+
+      it('should return empty array for empty retention', () => {
+        const retention = {};
+
+        const result = component['buildRetentionCopy'](retention);
+
+        expect(result).toEqual([]);
+      });
+
+      it('should return empty array for undefined retention', () => {
+        const result = component['buildRetentionCopy'](undefined);
+
+        expect(result).toEqual([]);
+      });
+
+      it('should filter out null retention values', () => {
+        const retention = { h: 24, d: null as any };
+
+        const result = component['buildRetentionCopy'](retention);
+
+        expect(result.length).toBe(1);
+      });
+    });
+
+    describe('closeSidePanel with schedule policies', () => {
+      it('should clear schedule policies when closing side panel', () => {
+        component.sidePanelOpen = true;
+        component.selectedPath = {} as any;
+        component.schedulePolicies = mockSchedulePolicies as any;
+        component.schedulePoliciesLoading = true;
+        component.removingSchedule = 'test';
+
+        component.closeSidePanel();
+
+        expect(component.sidePanelOpen).toBe(false);
+        expect(component.selectedPath).toBeNull();
+        expect(component.schedulePolicies).toEqual([]);
+        expect(component.schedulePoliciesLoading).toBe(false);
+        expect(component.removingSchedule).toBe('');
+      });
+    });
+
+    describe('onPathClick with schedule policies', () => {
+      it('should load schedule policies when path is clicked', fakeAsync(() => {
+        snapshotScheduleService.getSnapshotSchedule.mockReturnValue(of(mockSchedulePolicies));
+        cephfsService.getMirrorStatus.mockReturnValue(of(mockMirrorStatusResponse));
+        component.fsName = 'test-fs';
+
+        const mockPath = {
+          path: '/path1',
+          syncStatus: 'syncing' as const,
+          currentSyncSnapshot: 'snap1',
+          lastSyncedSnapshot: 'snap0'
+        };
+
+        component.onPathClick(mockPath as any);
+        tick();
+
+        expect(snapshotScheduleService.getSnapshotSchedule).toHaveBeenCalledWith(
+          '/path1',
+          'test-fs',
+          false
+        );
+      }));
+    });
+  });
 });
index e40ce153beeca4aa16024cf1258b7217b66c681d..873af47ad864d59f2ba046ba5d23eaa712198e1c 100644 (file)
@@ -9,11 +9,13 @@ import {
 import { ActivatedRoute } from '@angular/router';
 import { Subscription } from 'rxjs';
 import { CephfsService } from '~/app/shared/api/cephfs.service';
+import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service';
 import { CdTableColumn } from '~/app/shared/models/cd-table-column';
 import { CdTableSelection } from '~/app/shared/models/cd-table-selection';
 import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { MirrorDirStatus, MirrorStatusResponse } from '~/app/shared/models/cephfs.model';
+import { MirrorPathSchedule } from '~/app/shared/models/snapshot-schedule';
 
 interface MirrorPath {
   path: string;
@@ -62,11 +64,16 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
   selectedPath: MirrorPath | null = null;
   sidePanelOpen = false;
   fsName: string = '';
+  schedulePolicies: MirrorPathSchedule[] = [];
+  schedulePoliciesLoading = false;
+  removingSchedule = '';
 
   private subscriptions = new Subscription();
+  private mirrorPathsSubscription?: Subscription;
 
   constructor(
     private cephfsService: CephfsService,
+    private snapshotScheduleService: CephfsSnapshotScheduleService,
     private route: ActivatedRoute,
     private formatterService: FormatterService
   ) {}
@@ -133,7 +140,8 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
       return;
     }
 
-    this.cephfsService.getMirrorStatus(this.fsName).subscribe(
+    this.mirrorPathsSubscription?.unsubscribe();
+    this.mirrorPathsSubscription = this.cephfsService.getMirrorStatus(this.fsName).subscribe(
       (data: MirrorStatusResponse) => {
         this.mirrorPaths = this.parseMirrorStatus(data);
         if (this.selectedPath) {
@@ -149,6 +157,7 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
         this.sidePanelOpen = false;
       }
     );
+    this.subscriptions.add(this.mirrorPathsSubscription);
   }
 
   parseMirrorStatus(data: MirrorStatusResponse): MirrorPath[] {
@@ -264,13 +273,229 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
   }
 
   onPathClick(path: MirrorPath): void {
-    this.selectedPath = path;
-    this.sidePanelOpen = true;
+    this.sidePanelOpen = false;
+    this.selectedPath = null;
+
+    setTimeout(() => {
+      this.selectedPath = path;
+      this.sidePanelOpen = true;
+      this.loadSchedulePolicies(path.path);
+      this.loadMirrorPaths();
+    });
   }
 
   closeSidePanel(): void {
     this.sidePanelOpen = false;
     this.selectedPath = null;
+    this.schedulePolicies = [];
+    this.schedulePoliciesLoading = false;
+    this.removingSchedule = '';
+  }
+
+  loadSchedulePolicies(path: string): void {
+    if (!this.fsName || !path) {
+      this.schedulePolicies = [];
+      return;
+    }
+
+    this.schedulePoliciesLoading = true;
+    this.subscriptions.add(
+      this.snapshotScheduleService.getSnapshotSchedule(path, this.fsName, false).subscribe(
+        (policies) => {
+          if (this.selectedPath?.path !== path) {
+            this.schedulePoliciesLoading = false;
+            return;
+          }
+
+          const normalizedPath = this.normalizePath(path);
+          this.schedulePolicies = policies
+            .filter((policy) => {
+              return (
+                this.normalizePath(policy.path) === normalizedPath ||
+                this.normalizePath(policy.rel_path) === normalizedPath
+              );
+            })
+            .filter(
+              (policy, index, filteredPolicies) =>
+                filteredPolicies.findIndex(
+                  (candidate) =>
+                    candidate.path === policy.path &&
+                    candidate.schedule === policy.schedule &&
+                    String(candidate.start) === String(policy.start)
+                ) === index
+            )
+            .map((policy) => this.buildSchedulePolicyViewModel(policy as MirrorPathSchedule));
+          this.schedulePoliciesLoading = false;
+        },
+        () => {
+          if (this.selectedPath?.path === path) {
+            this.schedulePolicies = [];
+          }
+          this.schedulePoliciesLoading = false;
+        }
+      )
+    );
+  }
+
+  removeSchedulePolicy(policy: MirrorPathSchedule): void {
+    if (!policy?.path || !policy?.schedule || !policy?.start || !this.fsName) {
+      return;
+    }
+
+    const retentionPolicy = policy.retention
+      ? Object.entries(policy.retention)
+          .filter(([, interval]) => interval !== null && interval !== undefined)
+          .map(([frequency, interval]) => `${interval}-${frequency}`)
+          .join('|')
+      : undefined;
+
+    this.removingSchedule = `${policy.path}@${policy.schedule}`;
+    this.subscriptions.add(
+      this.snapshotScheduleService
+        .delete({
+          path: policy.path,
+          schedule: policy.schedule,
+          start: policy.start,
+          fs: policy.fs || this.fsName,
+          retentionPolicy
+        })
+        .subscribe(
+          () => {
+            this.removingSchedule = '';
+            this.loadSchedulePolicies(policy.path);
+          },
+          () => {
+            this.removingSchedule = '';
+          }
+        )
+    );
+  }
+
+  get selectedPathSyncStatusIcon(): keyof typeof ICON_TYPE {
+    return this.getSyncStatusIcon(this.selectedPath?.syncStatus || '');
+  }
+
+  get selectedPathSyncStatusClass(): string {
+    return this.getSyncStatusClass(this.selectedPath?.syncStatus || '');
+  }
+
+  get selectedPathSyncStatusLabel(): string {
+    return this.selectedPath?.syncStatus ? this.toTitleCase(this.selectedPath.syncStatus) : '-';
+  }
+
+  get showSelectedPathProgress(): boolean {
+    return (
+      this.selectedPath?.syncStatus === 'syncing' && this.selectedPath?.syncProgress !== undefined
+    );
+  }
+
+  private buildSchedulePolicyViewModel(policy: MirrorPathSchedule): MirrorPathSchedule {
+    const retention =
+      typeof policy.retention === 'string'
+        ? {}
+        : ((policy.retention || {}) as Record<string, number>);
+    const retentionCopy = this.buildRetentionCopy(retention);
+
+    return {
+      ...policy,
+      retention,
+      scheduleCopy: this.snapshotScheduleService.parseScheduleCopy(policy.schedule),
+      retentionCopy,
+      nextSync: this.calculateNextSync(policy),
+      scheduleText: policy.schedule || '-',
+      retentionText: this.formatRetentionCopy(retentionCopy),
+      statusLabel: this.getScheduleStatusLabel(policy.active),
+      statusIcon: this.getScheduleStatusIcon(policy.active),
+      removeId: `${policy.path}@${policy.schedule}`
+    };
+  }
+
+  private getScheduleStatusLabel(active: boolean): string {
+    return active ? $localize`Active` : $localize`Inactive`;
+  }
+
+  private formatRetentionCopy(retentionCopy?: string[]): string {
+    return retentionCopy?.length ? retentionCopy.join(', ') : '-';
+  }
+
+  private buildRetentionCopy(retention?: Record<string, number>): string[] {
+    if (!retention || !Object.keys(retention).length) {
+      return [];
+    }
+
+    const retentionLabels: Record<string, string> = {
+      h: $localize`hourly`,
+      d: $localize`daily`,
+      w: $localize`weekly`,
+      M: $localize`monthly`,
+      m: $localize`minutely`,
+      y: $localize`yearly`,
+      n: $localize`latest snapshots`
+    };
+
+    return Object.entries(retention)
+      .filter(([, interval]) => interval !== null && interval !== undefined)
+      .map(([frequency, interval]) => `${interval} ${retentionLabels[frequency] || frequency}`);
+  }
+
+  formatScheduleDate(value?: string | Date | null): string {
+    if (!value) {
+      return '-';
+    }
+
+    const date = new Date(value);
+    return Number.isNaN(date.getTime()) ? '-' : date.toLocaleString();
+  }
+
+  private calculateNextSync(policy: MirrorPathSchedule): string {
+    if (!policy?.schedule) {
+      return '-';
+    }
+
+    const baseTime = policy.last ?? policy.start;
+    if (!baseTime) {
+      return '-';
+    }
+
+    const baseDate = new Date(baseTime);
+    if (Number.isNaN(baseDate.getTime())) {
+      return '-';
+    }
+
+    const scheduleMatch = policy.schedule.trim().match(/^(\d+)([a-zA-Z])$/);
+    if (!scheduleMatch) {
+      return '-';
+    }
+
+    const interval = parseInt(scheduleMatch[1], 10);
+    const unit = scheduleMatch[2];
+    const nextSync = new Date(baseDate);
+
+    switch (unit) {
+      case 'm':
+        nextSync.setMinutes(nextSync.getMinutes() + interval);
+        break;
+      case 'h':
+        nextSync.setHours(nextSync.getHours() + interval);
+        break;
+      case 'd':
+        nextSync.setDate(nextSync.getDate() + interval);
+        break;
+      case 'w':
+        nextSync.setDate(nextSync.getDate() + interval * 7);
+        break;
+      case 'M':
+        nextSync.setMonth(nextSync.getMonth() + interval);
+        break;
+      case 'y':
+      case 'Y':
+        nextSync.setFullYear(nextSync.getFullYear() + interval);
+        break;
+      default:
+        return '-';
+    }
+
+    return nextSync.toLocaleString();
   }
 
   getSyncStatusIcon(status: string): keyof typeof ICON_TYPE {
@@ -288,6 +513,10 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
     }
   }
 
+  getScheduleStatusIcon(active: boolean): keyof typeof ICON_TYPE {
+    return active ? 'success' : 'warning';
+  }
+
   getSyncStatusClass(status: string): string {
     switch (status) {
       case 'syncing':
@@ -302,4 +531,13 @@ export class CephfsMirroringFsMirrorPathsComponent implements OnInit, OnDestroy
         return '';
     }
   }
+
+  private normalizePath(p?: string): string {
+    if (!p) return '';
+    return p.replace(/([/](\.\.?)){1,}\s*$/, '').replace(/\/$/, '') || '/';
+  }
+
+  private toTitleCase(value: string): string {
+    return value ? value.charAt(0).toUpperCase() + value.slice(1) : '-';
+  }
 }
index 35840e1a50062dda363086a63b99ce9bd840ff03..02aff4ba7a599d8d34c0bfc92f38968af12e47a6 100644 (file)
@@ -142,12 +142,37 @@ export interface MirrorPeerListEntry {
 
 export type MirrorPeerList = Record<string, MirrorPeerListEntry>;
 
+export interface MirrorSyncCrawl {
+  state?: string;
+  duration?: string;
+}
+
+export interface MirrorSyncBytes {
+  sync_bytes?: string;
+  total_bytes?: string;
+  sync_percent?: string;
+}
+
+export interface MirrorSyncFiles {
+  sync_files?: number;
+  total_files?: number;
+  sync_percent?: string;
+}
+
 export interface MirrorSyncedSnap {
   id?: number;
   name?: string;
+  'sync-mode'?: string;
   sync_bytes?: number | string;
   sync_duration?: number | string;
   sync_time_stamp?: number | string;
+  avg_read_throughput_bytes?: string;
+  avg_write_throughput_bytes?: string;
+  crawl?: MirrorSyncCrawl;
+  datasync_queue_wait?: MirrorSyncCrawl;
+  bytes?: MirrorSyncBytes;
+  files?: MirrorSyncFiles;
+  eta?: string;
 }
 
 export interface MirrorSyncProgress {
index aac8927c5c0514b806771e3ca68c6eae546f51ef..65ea84f52b8295d35b0ad46a719a8756d2ea3cb0 100644 (file)
@@ -1,4 +1,5 @@
 import { NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap';
+import { ICON_TYPE } from '~/app/shared/enum/icons.enum';
 
 export interface SnapshotSchedule {
   fs?: string;
@@ -18,6 +19,19 @@ export interface SnapshotSchedule {
   status: 'Active' | 'Inactive';
 }
 
+export interface MirrorPathSchedule extends SnapshotSchedule {
+  retention?: Record<string, number>;
+  retentionCopy?: string[];
+  scheduleCopy?: string;
+  nextSync?: string;
+  scheduleText?: string;
+  retentionText?: string;
+  statusClass?: string;
+  statusLabel?: string;
+  statusIcon?: keyof typeof ICON_TYPE;
+  removeId?: string;
+}
+
 export interface SnapshotScheduleFormValue {
   directory: string;
   startDate: string;