From d7c9691623542f2bcc238c307d2679a6d51fa129 Mon Sep 17 00:00:00 2001 From: Ivo Almeida Date: Wed, 13 Dec 2023 01:08:52 +0000 Subject: [PATCH] mgr/dashboard: added snap schedule form Fixes: https://tracker.ceph.com/issues/63827 Signed-off-by: Ivo Almeida --- .../mgr/dashboard/controllers/cephfs.py | 42 ++- ...ephfs-snapshotschedule-form.component.html | 179 ++++++++++++ ...ephfs-snapshotschedule-form.component.scss | 0 ...fs-snapshotschedule-form.component.spec.ts | 79 ++++++ .../cephfs-snapshotschedule-form.component.ts | 257 ++++++++++++++++++ .../cephfs-snapshotschedule-list.component.ts | 21 +- .../cephfs-tabs/cephfs-tabs.component.html | 1 + .../src/app/ceph/cephfs/cephfs.module.ts | 14 +- .../api/cephfs-snapshot-schedule.service.ts | 79 +++++- .../src/app/shared/api/cephfs.service.ts | 4 +- .../src/app/shared/enum/icons.enum.ts | 1 + .../app/shared/enum/repeat-frequency.enum.ts | 5 + .../shared/enum/retention-frequency.enum.ts | 8 + .../app/shared/models/snapshot-schedule.ts | 16 ++ .../shared/services/task-message.service.ts | 6 + src/pybind/mgr/dashboard/openapi.yaml | 49 +++- 16 files changed, 736 insertions(+), 25 deletions(-) create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.spec.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/enum/repeat-frequency.enum.ts create mode 100644 src/pybind/mgr/dashboard/frontend/src/app/shared/enum/retention-frequency.enum.ts diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 01827e3ef0ec3..86f112522c8fc 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -941,25 +941,24 @@ class CephFsSnapshotClone(RESTController): return f'Clone {clone_name} created successfully' -@APIRouter('/cephfs/snaphost/schedule', Scope.CEPHFS) +@APIRouter('/cephfs/snapshot/schedule', Scope.CEPHFS) @APIDoc("Cephfs Snapshot Scheduling API", "CephFSSnapshotSchedule") class CephFSSnapshotSchedule(RESTController): def list(self, fs: str, path: str = '/', recursive: bool = True): error_code, out, err = mgr.remote('snap_schedule', 'snap_schedule_list', - path, recursive, fs, 'plain') - + path, recursive, fs, None, None, 'plain') if len(out) == 0: return [] snapshot_schedule_list = out.split('\n') - output = [] + output: list[Any] = [] for snap in snapshot_schedule_list: current_path = snap.strip().split(' ')[0] error_code, status_out, err = mgr.remote('snap_schedule', 'snap_schedule_get', - current_path, fs, 'plain') - output.append(json.loads(status_out)) + current_path, fs, None, None, 'json') + output = output + json.loads(status_out) output_json = json.dumps(output) @@ -967,5 +966,34 @@ class CephFSSnapshotSchedule(RESTController): raise DashboardException( f'Failed to get list of snapshot schedules for path {path}: {err}' ) - return json.loads(output_json) + + def create(self, fs: str, path: str, snap_schedule: str, start: str, retention_policy=None): + error_code, _, err = mgr.remote('snap_schedule', + 'snap_schedule_add', + path, + snap_schedule, + start, + fs) + + if retention_policy: + retention_policies = retention_policy.split('|') + for retention in retention_policies: + retention_count = retention.split('-')[0] + retention_spec_or_period = retention.split('-')[1] + error_code_retention, _, err_retention = mgr.remote('snap_schedule', + 'snap_schedule_retention_add', + path, + retention_spec_or_period, + retention_count, + fs) + if error_code_retention != 0: + raise DashboardException( + f'Failed to add retention policy for path {path}: {err_retention}' + ) + if error_code != 0: + raise DashboardException( + f'Failed to create snapshot schedule for path {path}: {err}' + ) + + return f'Snapshot schedule for path {path} created successfully' diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html new file mode 100644 index 0000000000000..9e9cde86b3250 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.html @@ -0,0 +1,179 @@ + + {{ action | titlecase }} {{ resource | upperFirst }} + +
+ + + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.spec.ts new file mode 100644 index 0000000000000..6a9fbcb942a68 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.spec.ts @@ -0,0 +1,79 @@ +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { CephfsSnapshotscheduleFormComponent } from './cephfs-snapshotschedule-form.component'; +import { + NgbActiveModal, + NgbDatepickerModule, + NgbTimepickerModule +} from '@ng-bootstrap/ng-bootstrap'; +import { ToastrModule } from 'ngx-toastr'; +import { SharedModule } from '~/app/shared/shared.module'; +import { RouterTestingModule } from '@angular/router/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { FormHelper, configureTestBed } from '~/testing/unit-test-helper'; +import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service'; + +describe('CephfsSnapshotscheduleFormComponent', () => { + let component: CephfsSnapshotscheduleFormComponent; + let fixture: ComponentFixture; + let formHelper: FormHelper; + let createSpy: jasmine.Spy; + + configureTestBed({ + declarations: [CephfsSnapshotscheduleFormComponent], + providers: [NgbActiveModal], + imports: [ + SharedModule, + ToastrModule.forRoot(), + ReactiveFormsModule, + HttpClientTestingModule, + RouterTestingModule, + NgbDatepickerModule, + NgbTimepickerModule + ] + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CephfsSnapshotscheduleFormComponent); + component = fixture.componentInstance; + component.fsName = 'test_fs'; + component.ngOnInit(); + formHelper = new FormHelper(component.snapScheduleForm); + createSpy = spyOn(TestBed.inject(CephfsSnapshotScheduleService), 'create').and.stub(); + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('should have a form open in modal', () => { + const nativeEl = fixture.debugElement.nativeElement; + expect(nativeEl.querySelector('cd-modal')).not.toBe(null); + }); + + it('should submit the form', () => { + const input = { + directory: '/test', + startDate: { + year: 2023, + month: 11, + day: 14 + }, + startTime: { + hour: 0, + minute: 6, + second: 22 + }, + repeatInterval: 4, + repeatFrequency: 'h' + }; + + formHelper.setMultipleValues(input); + component.snapScheduleForm.get('directory').setValue('/test'); + component.submit(); + + expect(createSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts new file mode 100644 index 0000000000000..5b6d900e7520a --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component.ts @@ -0,0 +1,257 @@ +import { ChangeDetectorRef, Component, OnInit } from '@angular/core'; +import { AbstractControl, FormArray, FormControl, FormGroup, Validators } from '@angular/forms'; +import { NgbActiveModal, NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; +import { uniq } from 'lodash'; +import { Observable, timer } from 'rxjs'; +import { map, switchMap } from 'rxjs/operators'; +import { CephfsSnapshotScheduleService } from '~/app/shared/api/cephfs-snapshot-schedule.service'; +import { CephfsService } from '~/app/shared/api/cephfs.service'; +import { ActionLabelsI18n, URLVerbs } from '~/app/shared/constants/app.constants'; +import { Icons } from '~/app/shared/enum/icons.enum'; +import { RepeatFrequency } from '~/app/shared/enum/repeat-frequency.enum'; +import { RetentionFrequency } from '~/app/shared/enum/retention-frequency.enum'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CephfsDir } from '~/app/shared/models/cephfs-directory-models'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { RetentionPolicy, SnapshotScheduleFormValue } from '~/app/shared/models/snapshot-schedule'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; + +const VALIDATON_TIMER = 300; + +@Component({ + selector: 'cd-cephfs-snapshotschedule-form', + templateUrl: './cephfs-snapshotschedule-form.component.html', + styleUrls: ['./cephfs-snapshotschedule-form.component.scss'] +}) +export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnInit { + fsName!: string; + id!: number; + isEdit = false; + icons = Icons; + repeatFrequencies = Object.entries(RepeatFrequency); + retentionFrequencies = Object.entries(RetentionFrequency); + + currentTime!: NgbTimeStruct; + minDate!: NgbDateStruct; + + snapScheduleForm!: CdFormGroup; + + action!: string; + resource!: string; + + columns!: CdTableColumn[]; + directories$!: Observable; + + constructor( + public activeModal: NgbActiveModal, + private actionLabels: ActionLabelsI18n, + private cephfsService: CephfsService, + private snapScheduleService: CephfsSnapshotScheduleService, + private taskWrapper: TaskWrapperService, + private cd: ChangeDetectorRef + ) { + super(); + this.resource = $localize`Snapshot schedule`; + + const currentDatetime = new Date(); + this.minDate = { + year: currentDatetime.getUTCFullYear(), + month: currentDatetime.getUTCMonth() + 1, + day: currentDatetime.getUTCDate() + }; + this.currentTime = { + hour: currentDatetime.getUTCHours(), + minute: currentDatetime.getUTCMinutes(), + second: currentDatetime.getUTCSeconds() + }; + } + + ngOnInit(): void { + this.action = this.actionLabels.CREATE; + this.directories$ = this.cephfsService.lsDir(this.id, '/', 3); + this.createForm(); + this.loadingReady(); + } + + get retentionPolicies() { + return this.snapScheduleForm.get('retentionPolicies') as FormArray; + } + + createForm() { + this.snapScheduleForm = new CdFormGroup( + { + directory: new FormControl(undefined, { + validators: [Validators.required] + }), + startDate: new FormControl(this.minDate, { + validators: [Validators.required] + }), + startTime: new FormControl(this.currentTime, { + validators: [Validators.required] + }), + repeatInterval: new FormControl(1, { + validators: [Validators.required, Validators.min(1)] + }), + repeatFrequency: new FormControl(RepeatFrequency.Daily, { + validators: [Validators.required] + }), + retentionPolicies: new FormArray([]) + }, + { + asyncValidators: [this.validateSchedule(), this.validateRetention()] + } + ); + } + + addRetentionPolicy() { + this.retentionPolicies.push( + new FormGroup({ + retentionInterval: new FormControl(1), + retentionFrequency: new FormControl(RetentionFrequency.Daily) + }) + ); + this.cd.detectChanges(); + } + + removeRetentionPolicy(idx: number) { + this.retentionPolicies.removeAt(idx); + this.cd.detectChanges(); + } + + parseDatetime(date: NgbDateStruct, time?: NgbTimeStruct): string { + return `${date.year}-${date.month}-${date.day}T${time.hour || '00'}:${time.minute || '00'}:${ + time.second || '00' + }`; + } + parseSchedule(interval: number, frequency: string): string { + return `${interval}${frequency}`; + } + + parseRetentionPolicies(retentionPolicies: RetentionPolicy[]) { + return retentionPolicies + ?.filter((r) => r?.retentionInterval !== null && r?.retentionFrequency !== null) + ?.map?.((r) => `${r.retentionInterval}-${r.retentionFrequency}`) + .join('|'); + } + + submit() { + if (this.snapScheduleForm.invalid) { + this.snapScheduleForm.setErrors({ cdSubmitButton: true }); + return; + } + + const values = this.snapScheduleForm.value as SnapshotScheduleFormValue; + + const snapScheduleObj = { + fs: this.fsName, + path: values.directory, + snap_schedule: this.parseSchedule(values.repeatInterval, values.repeatFrequency), + start: this.parseDatetime(values.startDate, values.startTime) + }; + + const retentionPoliciesValues = this.parseRetentionPolicies(values?.retentionPolicies); + if (retentionPoliciesValues) { + snapScheduleObj['retention_policy'] = retentionPoliciesValues; + } + + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('cephfs/snapshot/schedule/' + URLVerbs.CREATE, { + path: snapScheduleObj.path + }), + call: this.snapScheduleService.create(snapScheduleObj) + }) + .subscribe({ + error: () => { + this.snapScheduleForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.activeModal.close(); + } + }); + } + + validateSchedule() { + return (frm: AbstractControl) => { + const directory = frm.get('directory'); + const repeatFrequency = frm.get('repeatFrequency'); + const repeatInterval = frm.get('repeatInterval'); + return timer(VALIDATON_TIMER).pipe( + switchMap(() => + this.snapScheduleService + .checkScheduleExists( + directory?.value, + this.fsName, + repeatInterval?.value, + repeatFrequency?.value + ) + .pipe( + map((exists: boolean) => { + if (exists) { + repeatFrequency?.setErrors({ notUnique: true }, { emitEvent: true }); + } else { + repeatFrequency?.setErrors(null); + } + return null; + }) + ) + ) + ); + }; + } + + getFormArrayItem(frm: FormGroup, frmArrayName: string, ctrl: string, idx: number) { + return (frm.get(frmArrayName) as FormArray)?.controls?.[idx]?.get?.(ctrl); + } + + validateRetention() { + return (frm: FormGroup) => { + return timer(VALIDATON_TIMER).pipe( + switchMap(() => { + const retentionList = (frm.get('retentionPolicies') as FormArray).controls?.map( + (ctrl) => { + return ctrl.get('retentionFrequency').value; + } + ); + if (uniq(retentionList)?.length !== retentionList?.length) { + this.getFormArrayItem( + frm, + 'retentionPolicies', + 'retentionFrequency', + retentionList.length - 1 + )?.setErrors?.({ + notUnique: true + }); + return null; + } + return this.snapScheduleService + .checkRetentionPolicyExists(frm.get('directory').value, this.fsName, retentionList) + .pipe( + map(({ exists, errorIndex }) => { + if (exists) { + this.getFormArrayItem( + frm, + 'retentionPolicies', + 'retentionFrequency', + errorIndex + )?.setErrors?.({ notUnique: true }); + } else { + (frm.get('retentionPolicies') as FormArray).controls?.forEach?.((_, i) => { + this.getFormArrayItem( + frm, + 'retentionPolicies', + 'retentionFrequency', + i + )?.setErrors?.(null); + }); + } + return null; + }) + ); + }) + ); + }; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-list/cephfs-snapshotschedule-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-list/cephfs-snapshotschedule-list.component.ts index 14752b7e2a8f3..6b406cfc1712f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-list/cephfs-snapshotschedule-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-snapshotschedule-list/cephfs-snapshotschedule-list.component.ts @@ -26,6 +26,8 @@ import { MgrModuleService } from '~/app/shared/api/mgr-module.service'; import { NotificationService } from '~/app/shared/services/notification.service'; import { BlockUI, NgBlockUI } from 'ng-block-ui'; import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CephfsSnapshotscheduleFormComponent } from '../cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component'; @Component({ selector: 'cd-cephfs-snapshotschedule-list', @@ -36,6 +38,7 @@ export class CephfsSnapshotscheduleListComponent extends CdForm implements OnInit, OnChanges, OnDestroy { @Input() fsName!: string; + @Input() id!: number; @ViewChild('pathTpl', { static: true }) pathTpl: any; @@ -65,7 +68,8 @@ export class CephfsSnapshotscheduleListComponent private authStorageService: AuthStorageService, private modalService: ModalService, private mgrModuleService: MgrModuleService, - private notificationService: NotificationService + private notificationService: NotificationService, + private actionLables: ActionLabelsI18n ) { super(); this.permissions = this.authStorageService.getPermissions(); @@ -112,7 +116,14 @@ export class CephfsSnapshotscheduleListComponent { prop: 'created', name: $localize`Created`, cellTransformation: CellTemplate.timeAgo } ]; - this.tableActions = []; + this.tableActions = [ + { + name: this.actionLables.CREATE, + permission: 'create', + icon: Icons.add, + click: () => this.openModal(true) + } + ]; } ngOnDestroy(): void { @@ -129,9 +140,11 @@ export class CephfsSnapshotscheduleListComponent openModal(edit = false) { this.modalService.show( - {}, + CephfsSnapshotscheduleFormComponent, { - fsName: 'fs1', + fsName: this.fsName, + id: this.id, + path: this.selection?.first()?.path, isEdit: edit }, { size: 'lg' } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html index d21d47034f802..a840692ed7673 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs-tabs/cephfs-tabs.component.html @@ -59,6 +59,7 @@ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts index 36ad6ddcb8745..b13273dc4a21e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cephfs/cephfs.module.ts @@ -3,7 +3,13 @@ import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { TreeModule } from '@circlon/angular-tree-component'; -import { NgbNavModule, NgbTooltipModule, NgbTypeaheadModule } from '@ng-bootstrap/ng-bootstrap'; +import { + NgbDatepickerModule, + NgbNavModule, + NgbTimepickerModule, + NgbTooltipModule, + NgbTypeaheadModule +} from '@ng-bootstrap/ng-bootstrap'; import { NgChartsModule } from 'ng2-charts'; import { AppRoutingModule } from '~/app/app-routing.module'; @@ -23,6 +29,7 @@ import { CephfsSubvolumeSnapshotsListComponent } from './cephfs-subvolume-snapsh import { CephfsSnapshotscheduleListComponent } from './cephfs-snapshotschedule-list/cephfs-snapshotschedule-list.component'; import { DataTableModule } from '../../shared/datatable/datatable.module'; import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapshots-list/cephfs-subvolume-snapshots-form/cephfs-subvolume-snapshots-form.component'; +import { CephfsSnapshotscheduleFormComponent } from './cephfs-snapshotschedule-form/cephfs-snapshotschedule-form.component'; @NgModule({ imports: [ @@ -36,7 +43,9 @@ import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapsh ReactiveFormsModule, NgbTypeaheadModule, NgbTooltipModule, - DataTableModule + DataTableModule, + NgbDatepickerModule, + NgbTimepickerModule ], declarations: [ CephfsDetailComponent, @@ -53,6 +62,7 @@ import { CephfsSubvolumeSnapshotsFormComponent } from './cephfs-subvolume-snapsh CephfsSubvolumegroupFormComponent, CephfsSubvolumeSnapshotsListComponent, CephfsSnapshotscheduleListComponent, + CephfsSnapshotscheduleFormComponent, CephfsSubvolumeSnapshotsFormComponent ] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-snapshot-schedule.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-snapshot-schedule.service.ts index ec9f58c0feec6..0666bb179e834 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-snapshot-schedule.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs-snapshot-schedule.service.ts @@ -1,8 +1,11 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs/internal/Observable'; +import { catchError, map } from 'rxjs/operators'; +import { intersection, isEqual, uniqWith } from 'lodash'; import { SnapshotSchedule } from '../models/snapshot-schedule'; -import { map } from 'rxjs/operators'; +import { of } from 'rxjs'; +import { RepeatFrequency } from '../enum/repeat-frequency.enum'; @Injectable({ providedIn: 'root' @@ -12,28 +15,86 @@ export class CephfsSnapshotScheduleService { constructor(private http: HttpClient) {} - getSnapshotScheduleList( + create(data: Record): Observable { + return this.http.post(`${this.baseURL}/snapshot/schedule`, data, { observe: 'response' }); + } + + checkScheduleExists( path: string, fs: string, - recursive = true - ): Observable { + interval: number, + frequency: RepeatFrequency + ): Observable { + return this.getSnapshotScheduleList(path, fs, false).pipe( + map((response) => { + const index = response.findIndex( + (x) => x.path === path && x.schedule === `${interval}${frequency}` + ); + return index > -1; + }), + catchError(() => { + return of(false); + }) + ); + } + + checkRetentionPolicyExists( + path: string, + fs: string, + retentionFrequencies: string[] + ): Observable<{ exists: boolean; errorIndex: number }> { + return this.getList(path, fs, false).pipe( + map((response) => { + let errorIndex = -1; + let exists = false; + const index = response.findIndex((x) => x.path === path); + const result = retentionFrequencies?.length + ? intersection(Object.keys(response?.[index]?.retention), retentionFrequencies) + : []; + exists = !!result?.length; + result?.forEach((r) => (errorIndex = retentionFrequencies.indexOf(r))); + + return { exists, errorIndex }; + }), + catchError(() => { + return of({ exists: false, errorIndex: -1 }); + }) + ); + } + + private getList(path: string, fs: string, recursive = true): Observable { return this.http .get( - `${this.baseURL}/snaphost/schedule?path=${path}&fs=${fs}&recursive=${recursive}` + `${this.baseURL}/snapshot/schedule?path=${path}&fs=${fs}&recursive=${recursive}` ) .pipe( - map((snapList: SnapshotSchedule[]) => + catchError(() => { + return of([]); + }) + ); + } + + getSnapshotScheduleList( + path: string, + fs: string, + recursive = true + ): Observable { + return this.getList(path, fs, recursive).pipe( + map((snapList: SnapshotSchedule[]) => + uniqWith( snapList.map((snapItem: SnapshotSchedule) => ({ ...snapItem, status: snapItem.active ? 'Active' : 'Inactive', subvol: snapItem?.subvol || ' - ', - retention: Object.values(snapItem.retention)?.length + retention: Object.values(snapItem?.retention || [])?.length ? Object.entries(snapItem.retention) ?.map?.(([frequency, interval]) => `${interval}${frequency.toLocaleUpperCase()}`) .join(' ') : '-' - })) + })), + isEqual ) - ); + ) + ); } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts index 6142d7359de26..a265ae7a26586 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/cephfs.service.ts @@ -21,8 +21,8 @@ export class CephfsService { return this.http.get(`${this.baseURL}`); } - lsDir(id: number, path?: string): Observable { - let apiPath = `${this.baseUiURL}/${id}/ls_dir?depth=2`; + lsDir(id: number, path?: string, depth: number = 2): Observable { + let apiPath = `${this.baseUiURL}/${id}/ls_dir?depth=${depth}`; if (path) { apiPath += `&path=${encodeURIComponent(path)}`; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts index d3515fb87e4b5..be454076b8621 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/icons.enum.ts @@ -82,6 +82,7 @@ export enum Icons { navicon = 'fa fa-navicon', // Navigation areaChart = 'fa fa-area-chart', // Area Chart, dashboard eye = 'fa fa-eye', // Observability + calendar = 'fa fa-calendar', externalUrl = 'fa fa-external-link', // links to external page /* Icons for special effect */ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/repeat-frequency.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/repeat-frequency.enum.ts new file mode 100644 index 0000000000000..db3563ed2b47c --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/repeat-frequency.enum.ts @@ -0,0 +1,5 @@ +export enum RepeatFrequency { + Hourly = 'h', + Daily = 'd', + Weekly = 'w' +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/retention-frequency.enum.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/retention-frequency.enum.ts new file mode 100644 index 0000000000000..44714dac94638 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/enum/retention-frequency.enum.ts @@ -0,0 +1,8 @@ +export enum RetentionFrequency { + Hourly = 'h', + Daily = 'd', + Weekly = 'w', + Monthly = 'm', + Yearly = 'y', + 'lastest snapshots' = 'n' +} 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 b1cea7466f672..af3b0f7c5e6b9 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,3 +1,5 @@ +import { NgbDateStruct, NgbTimeStruct } from '@ng-bootstrap/ng-bootstrap'; + export interface SnapshotSchedule { fs?: string; subvol?: string; @@ -15,3 +17,17 @@ export interface SnapshotSchedule { active: boolean; status: 'Active' | 'Inactive'; } + +export interface SnapshotScheduleFormValue { + directory: string; + startDate: NgbDateStruct; + startTime: NgbTimeStruct; + repeatInterval: number; + repeatFrequency: string; + retentionPolicies: RetentionPolicy[]; +} + +export interface RetentionPolicy { + retentionInterval: number; + retentionFrequency: string; +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index dba742fbf7831..f631842919c0f 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -387,6 +387,9 @@ export class TaskMessageService { 'cephfs/subvolume/snapshot/delete': this.newTaskMessage( this.commonOperations.delete, (metadata) => this.snapshot(metadata) + ), + 'cephfs/snapshot/schedule/create': this.newTaskMessage(this.commonOperations.add, (metadata) => + this.snapshotSchedule(metadata) ) }; @@ -459,6 +462,9 @@ export class TaskMessageService { return $localize`snapshot '${metadata.snapshotName}'`; } + snapshotSchedule(metadata: any) { + return $localize`snapshot schedule for path '${metadata?.path}'`; + } crudMessageId(id: string) { return $localize`${id}`; } diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 54f21e9484ae4..ad681795dc07e 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -1758,7 +1758,7 @@ paths: summary: Rename CephFS Volume tags: - Cephfs - /api/cephfs/snaphost/schedule: + /api/cephfs/snapshot/schedule: get: parameters: - in: query @@ -1795,6 +1795,53 @@ paths: - jwt: [] tags: - CephFSSnapshotSchedule + post: + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + fs: + type: string + path: + type: string + retention_policy: + type: string + snap_schedule: + type: string + start: + type: string + required: + - fs + - path + - snap_schedule + - start + type: object + responses: + '201': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource created. + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + tags: + - CephFSSnapshotSchedule /api/cephfs/subvolume: post: parameters: [] -- 2.39.5