<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>
+}
+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;
name: 'snap-last',
sync_time_stamp: '1583.101609s'
},
- snaps_synced: 10,
- snaps_deleted: 2,
- snaps_renamed: 1
+ snaps_synced: 10
}
}
},
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+)/);
provide: CephfsService,
useValue: cephfsServiceMock
},
+ {
+ provide: CephfsSnapshotScheduleService,
+ useValue: snapshotScheduleServiceMock
+ },
{
provide: FormatterService,
useValue: formatterServiceMock
}
}
],
- schemas: [NO_ERRORS_SCHEMA]
+ schemas: [NO_ERRORS_SCHEMA],
+ imports: [HttpClientModule, RouterTestingModule, SharedModule]
}).compileComponents();
cephfsService = TestBed.inject(CephfsService);
expect(result[0].lastSyncedSnapshot).toBe('snap-last');
expect(result[0].lastSyncedTime).toBe('1583.101609s');
expect(result[0].snapshotCount).toBe(10);
- expect(result[0].checkpointCount).toBe(2);
- expect(result[0].renamedSnapshotCount).toBe(1);
expect(result[0].filesSynced).toBe(100);
expect(result[0].totalFiles).toBe(200);
expect(result[0].syncProgress).toBe(50);
expect(result[1].currentSyncSnapshot).toBe('snap-current-2');
expect(result[1].lastSyncedSnapshot).toBe('snap-last-2');
expect(result[1].snapshotCount).toBe(5);
- expect(result[1].checkpointCount).toBe(1);
expect(result[1].filesSynced).toBe(50);
expect(result[1].totalFiles).toBe(100);
expect(result[1].bytesSynced).toBe(1073741824);
expect(result[0].currentSyncSnapshot).toBe('-');
expect(result[0].lastSyncedSnapshot).toBe('-');
expect(result[0].snapshotCount).toBe(0);
- expect(result[0].checkpointCount).toBe(0);
expect(result[0].syncProgress).toBe(0);
});
});
});
describe('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,
};
component.onPathClick(mockPath as any);
+ tick();
expect(component.selectedPath).toBe(mockPath);
expect(component.sidePanelOpen).toBe(true);
- });
+ }));
});
describe('closeSidePanel', () => {
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
+ );
+ }));
+ });
+ });
});
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;
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
) {}
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) {
this.sidePanelOpen = false;
}
);
+ this.subscriptions.add(this.mirrorPathsSubscription);
}
parseMirrorStatus(data: MirrorStatusResponse): MirrorPath[] {
}
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 {
}
}
+ getScheduleStatusIcon(active: boolean): keyof typeof ICON_TYPE {
+ return active ? 'success' : 'warning';
+ }
+
getSyncStatusClass(status: string): string {
switch (status) {
case 'syncing':
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) : '-';
+ }
}