From c04f92828b2081bfb60e8e6165d7ab3c48df2953 Mon Sep 17 00:00:00 2001 From: Aashish Sharma Date: Mon, 29 Jun 2026 18:35:36 +0530 Subject: [PATCH] mgr/dashboard: Add schedule policy tab to Mirro paths table -> side panel details Signed-off-by: Aashish Sharma --- ...s-mirroring-fs-mirror-paths.component.html | 286 +++++---- ...irroring-fs-mirror-paths.component.spec.ts | 569 +++++++++++++++++- ...hfs-mirroring-fs-mirror-paths.component.ts | 244 +++++++- .../src/app/shared/models/cephfs.model.ts | 25 + .../app/shared/models/snapshot-schedule.ts | 14 + 5 files changed, 1020 insertions(+), 118 deletions(-) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html index 1549bf945cc..65a529a2c4e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.html @@ -23,12 +23,12 @@ -
+
- {{ row.syncStatus | titlecase }} + {{ row.syncStatus | titlecase }}
@@ -37,121 +37,205 @@ let-row="data.row">
{{ row.currentSyncSnapshot }}
-
- Time remaining: {{ row.currentSyncEta }} -
+ @if (row.currentSyncEta) { +
+ Time remaining: {{ row.currentSyncEta }} +
+ }
- -
-
- - -
- -
-
Replication status
- -
-
Sync status
-
- - - {{ selectedPath.syncStatus | titlecase }} +@if (selectedPath) { + +
+ + +
+
+
Replication status
+ +
+
Sync status
+
+ + + {{ selectedPathSyncStatusLabel }} +
-
-
-
Current snapshot
-
{{ selectedPath.currentSyncSnapshot }}
-
+
+
Current snapshot
+
{{ selectedPath.currentSyncSnapshot }}
+
-
-
Last replicated snapshot
-
{{ selectedPath.lastSyncedSnapshot }}
+
+
Last replicated snapshot
+
{{ selectedPath.lastSyncedSnapshot }}
+
-
- -
-
+ @if (showSelectedPathProgress) { +
+
-
Current snapshot progress
+
Current snapshot progress
-
-
- {{ selectedPath.syncProgress }}% synced -
- - -
+
+
+ {{ selectedPath.syncProgress }}% synced +
+ + +
-
-
Files Synced
-
{{ selectedPath.filesSynced }}/{{ selectedPath.totalFiles }}
-
+ @if (selectedPath.filesSynced !== undefined && selectedPath.totalFiles !== undefined) { +
+
Files Synced
+
{{ selectedPath.filesSynced }}/{{ selectedPath.totalFiles }}
+
+ } -
-
Bytes Synced
-
{{ selectedPath.bytesSynced | dimlessBinary }}/{{ selectedPath.totalBytes | dimlessBinary }}
-
+ @if (selectedPath.bytesSynced !== undefined && selectedPath.totalBytes !== undefined) { +
+
Bytes Synced
+
{{ selectedPath.bytesSynced | dimlessBinary }}/{{ selectedPath.totalBytes | dimlessBinary }}
+
+ } -
-
Estimated time remaining
-
{{ selectedPath.currentSyncEta }}
-
+ @if (selectedPath.currentSyncEta) { +
+
Estimated time remaining
+
{{ selectedPath.currentSyncEta }}
+
+ } +
+ }
-
- - - -
-
- Snapshot information will be displayed here. + + + +
+
+ Snapshot information will be displayed here. +
-
- - - -
-
- Schedule policy information will be displayed here. + + + +
+ Total policies applied to path: + {{ schedulePolicies.length }} + + @if (schedulePoliciesLoading) { + + + } @else { + @if (!schedulePolicies.length) { +
+ + No schedule policies applied to this path. + +
+ } + + @for (policy of schedulePolicies; track policy.removeId; let last = $last) { +
+
+ {{ policy.scheduleCopy || policy.scheduleText }} +
+ +
+
+
Schedule
+
{{ policy.scheduleText }}
+
+ +
+
Retention
+
{{ policy.retentionText }}
+
+ +
+
Status
+
+ + + {{ policy.statusLabel }} +
+
+
+
+
+
Last sync
+
{{ formatScheduleDate(policy.last) }}
+
+ +
+
Next sync
+
{{ policy.nextSync }}
+
+ +
+
+ + + + @if (!last) { +
+ } +
+ } + }
-
- - + +
-
-
+ +} 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 index 0569eba8ef7..70c6ea373e4 100644 --- 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 @@ -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 + ); + })); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts index e40ce153bee..873af47ad86 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-mirroring-fs-mirror-paths/cephfs-mirroring-fs-mirror-paths.component.ts @@ -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); + 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[] { + if (!retention || !Object.keys(retention).length) { + return []; + } + + const retentionLabels: Record = { + 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) : '-'; + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts index 35840e1a500..02aff4ba7a5 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/cephfs.model.ts @@ -142,12 +142,37 @@ export interface MirrorPeerListEntry { export type MirrorPeerList = Record; +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 { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/snapshot-schedule.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/snapshot-schedule.ts index aac8927c5c0..65ea84f52b8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/snapshot-schedule.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/snapshot-schedule.ts @@ -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; + 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; -- 2.47.3