From bd2daf6ee36b3c0883d137f4081f0ef43fda7979 Mon Sep 17 00:00:00 2001 From: Ivo Almeida Date: Thu, 1 Feb 2024 18:15:19 +0000 Subject: [PATCH] mgr/dashboard: snapshot schedule edit form Fixes: https://tracker.ceph.com/issues/64331 Signed-off-by: Ivo Almeida --- .../mgr/dashboard/controllers/cephfs.py | 32 +++- .../cephfs-snapshotschedule-form.component.ts | 164 +++++++++++++++--- .../cephfs-snapshotschedule-list.component.ts | 10 ++ .../api/cephfs-snapshot-schedule.service.ts | 26 ++- .../shared/services/task-message.service.ts | 3 + src/pybind/mgr/dashboard/openapi.yaml | 84 +++++++-- 6 files changed, 265 insertions(+), 54 deletions(-) diff --git a/src/pybind/mgr/dashboard/controllers/cephfs.py b/src/pybind/mgr/dashboard/controllers/cephfs.py index 86f112522c8fc..1437962723ff2 100644 --- a/src/pybind/mgr/dashboard/controllers/cephfs.py +++ b/src/pybind/mgr/dashboard/controllers/cephfs.py @@ -1,10 +1,11 @@ # -*- coding: utf-8 -*- +# pylint: disable=too-many-lines import errno import json import logging import os from collections import defaultdict -from typing import Any, Dict +from typing import Any, Dict, List import cephfs import cherrypy @@ -952,7 +953,7 @@ class CephFSSnapshotSchedule(RESTController): return [] snapshot_schedule_list = out.split('\n') - output: list[Any] = [] + output: List[Any] = [] for snap in snapshot_schedule_list: current_path = snap.strip().split(' ')[0] @@ -997,3 +998,30 @@ class CephFSSnapshotSchedule(RESTController): ) return f'Snapshot schedule for path {path} created successfully' + + def set(self, fs: str, path: str, retention_to_add=None, retention_to_remove=None): + def editRetentionPolicies(method, retention_policy): + if not retention_policy: + return + + 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', + method, + path, + retention_spec_or_period, + retention_count, + fs, + None, + None) + if error_code_retention != 0: + raise DashboardException( + f'Failed to add/remove retention policy for path {path}: {err_retention}' + ) + + editRetentionPolicies('snap_schedule_retention_rm', retention_to_remove) + editRetentionPolicies('snap_schedule_retention_add', retention_to_add) + + return f'Retention policies for snapshot schedule on path {path} updated successfully' 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 index 41e55c72599ac..7459a7472c5f2 100644 --- 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 @@ -14,7 +14,11 @@ 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 { FinishedTask } from '~/app/shared/models/finished-task'; -import { RetentionPolicy, SnapshotScheduleFormValue } from '~/app/shared/models/snapshot-schedule'; +import { + RetentionPolicy, + SnapshotSchedule, + SnapshotScheduleFormValue +} from '~/app/shared/models/snapshot-schedule'; import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; const VALIDATON_TIMER = 300; @@ -27,11 +31,17 @@ const DEBOUNCE_TIMER = 300; }) export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnInit { fsName!: string; + path!: string; + schedule!: string; + retention!: string; + start!: string; + status!: string; id!: number; isEdit = false; icons = Icons; repeatFrequencies = Object.entries(RepeatFrequency); retentionFrequencies = Object.entries(RetentionFrequency); + retentionPoliciesToRemove: RetentionPolicy[] = []; currentTime!: NgbTimeStruct; minDate!: NgbDateStruct; @@ -71,7 +81,7 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni this.action = this.actionLabels.CREATE; this.directoryStore.loadDirectories(this.id, '/', 3); this.createForm(); - this.loadingReady(); + this.isEdit ? this.populateForm() : this.loadingReady(); } get retentionPolicies() { @@ -91,6 +101,50 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni ) ); + populateForm() { + this.action = this.actionLabels.EDIT; + this.snapScheduleService.getSnapshotSchedule(this.path, this.fsName, false).subscribe({ + next: (response: SnapshotSchedule[]) => { + const first = response.find((x) => x.path === this.path); + this.snapScheduleForm.get('directory').disable(); + this.snapScheduleForm.get('directory').setValue(first.path); + this.snapScheduleForm.get('startDate').disable(); + this.snapScheduleForm.get('startDate').setValue({ + year: new Date(first.start).getUTCFullYear(), + month: new Date(first.start).getUTCMonth() + 1, + day: new Date(first.start).getUTCDate() + }); + this.snapScheduleForm.get('startTime').disable(); + this.snapScheduleForm.get('startTime').setValue({ + hour: new Date(first.start).getUTCHours(), + minute: new Date(first.start).getUTCMinutes(), + second: new Date(first.start).getUTCSeconds() + }); + this.snapScheduleForm.get('repeatInterval').disable(); + this.snapScheduleForm.get('repeatInterval').setValue(first.schedule.split('')?.[0]); + this.snapScheduleForm.get('repeatFrequency').disable(); + this.snapScheduleForm.get('repeatFrequency').setValue(first.schedule.split('')?.[1]); + + // retention policies + first.retention && + Object.entries(first.retention).forEach(([frequency, interval], idx) => { + const freqKey = Object.keys(RetentionFrequency)[ + Object.values(RetentionFrequency).indexOf(frequency as any) + ]; + this.retentionPolicies.push( + new FormGroup({ + retentionInterval: new FormControl(interval), + retentionFrequency: new FormControl(RetentionFrequency[freqKey]) + }) + ); + this.retentionPolicies.controls[idx].get('retentionInterval').disable(); + this.retentionPolicies.controls[idx].get('retentionFrequency').disable(); + }); + this.loadingReady(); + } + }); + } + createForm() { this.snapScheduleForm = new CdFormGroup( { @@ -128,11 +182,19 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni } removeRetentionPolicy(idx: number) { + if (this.isEdit && this.retentionPolicies.at(idx).disabled) { + const values = this.retentionPolicies.at(idx).value as RetentionPolicy; + this.retentionPoliciesToRemove.push(values); + } this.retentionPolicies.removeAt(idx); + this.retentionPolicies.controls.forEach((x) => + x.get('retentionFrequency').updateValueAndValidity() + ); this.cd.detectChanges(); } parseDatetime(date: NgbDateStruct, time?: NgbTimeStruct): string { + if (!date || !time) return null; return `${date.year}-${date.month}-${date.day}T${time.hour || '00'}:${time.minute || '00'}:${ time.second || '00' }`; @@ -156,33 +218,69 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni 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) - }; + if (this.isEdit) { + const retentionPoliciesToAdd = (this.snapScheduleForm.get( + 'retentionPolicies' + ) as FormArray).controls + ?.filter( + (ctrl) => + !ctrl.get('retentionInterval').disabled && !ctrl.get('retentionFrequency').disabled + ) + .map((ctrl) => ({ + retentionInterval: ctrl.get('retentionInterval').value, + retentionFrequency: ctrl.get('retentionFrequency').value + })); - const retentionPoliciesValues = this.parseRetentionPolicies(values?.retentionPolicies); - if (retentionPoliciesValues) { - snapScheduleObj['retention_policy'] = retentionPoliciesValues; - } + const updateObj = { + fs: this.fsName, + path: this.path, + retention_to_add: this.parseRetentionPolicies(retentionPoliciesToAdd) || null, + retention_to_remove: this.parseRetentionPolicies(this.retentionPoliciesToRemove) || null + }; - 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(); - } - }); + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('cephfs/snapshot/schedule/' + URLVerbs.EDIT, { + path: this.path + }), + call: this.snapScheduleService.update(updateObj) + }) + .subscribe({ + error: () => { + this.snapScheduleForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.activeModal.close(); + } + }); + } else { + 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() { @@ -190,6 +288,11 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni const directory = frm.get('directory'); const repeatFrequency = frm.get('repeatFrequency'); const repeatInterval = frm.get('repeatInterval'); + + if (this.isEdit) { + return of(null); + } + return timer(VALIDATON_TIMER).pipe( switchMap(() => this.snapScheduleService @@ -239,7 +342,12 @@ export class CephfsSnapshotscheduleFormComponent extends CdForm implements OnIni return null; } return this.snapScheduleService - .checkRetentionPolicyExists(frm.get('directory').value, this.fsName, retentionList) + .checkRetentionPolicyExists( + frm.get('directory').value, + this.fsName, + retentionList, + this.retentionPoliciesToRemove?.map?.((rp) => rp.retentionFrequency) || [] + ) .pipe( map(({ exists, errorIndex }) => { if (exists) { 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 6b406cfc1712f..b6b52a15c99d2 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 @@ -121,6 +121,12 @@ export class CephfsSnapshotscheduleListComponent name: this.actionLables.CREATE, permission: 'create', icon: Icons.add, + click: () => this.openModal(false) + }, + { + name: this.actionLables.EDIT, + permission: 'update', + icon: Icons.edit, click: () => this.openModal(true) } ]; @@ -145,6 +151,10 @@ export class CephfsSnapshotscheduleListComponent fsName: this.fsName, id: this.id, path: this.selection?.first()?.path, + schedule: this.selection?.first()?.schedule, + retention: this.selection?.first()?.retention, + start: this.selection?.first()?.start, + status: this.selection?.first()?.status, isEdit: edit }, { size: 'lg' } 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 0666bb179e834..0719089c249f2 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 @@ -19,6 +19,14 @@ export class CephfsSnapshotScheduleService { return this.http.post(`${this.baseURL}/snapshot/schedule`, data, { observe: 'response' }); } + update(data: Record): Observable { + return this.http.put( + `${this.baseURL}/snapshot/schedule/${data.fs}/${encodeURIComponent(data.path)}`, + data, + { observe: 'response' } + ); + } + checkScheduleExists( path: string, fs: string, @@ -41,15 +49,21 @@ export class CephfsSnapshotScheduleService { checkRetentionPolicyExists( path: string, fs: string, - retentionFrequencies: string[] + retentionFrequencies: string[], + retentionFrequenciesRemoved: string[] = [] ): Observable<{ exists: boolean; errorIndex: number }> { - return this.getList(path, fs, false).pipe( + return this.getSnapshotSchedule(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) + ? intersection( + Object.keys(response?.[index]?.retention).filter( + (v) => !retentionFrequenciesRemoved.includes(v) + ), + retentionFrequencies + ) : []; exists = !!result?.length; result?.forEach((r) => (errorIndex = retentionFrequencies.indexOf(r))); @@ -62,10 +76,10 @@ export class CephfsSnapshotScheduleService { ); } - private getList(path: string, fs: string, recursive = true): Observable { + getSnapshotSchedule(path: string, fs: string, recursive = true): Observable { return this.http .get( - `${this.baseURL}/snapshot/schedule?path=${path}&fs=${fs}&recursive=${recursive}` + `${this.baseURL}/snapshot/schedule/${fs}?path=${path}&recursive=${recursive}` ) .pipe( catchError(() => { @@ -79,7 +93,7 @@ export class CephfsSnapshotScheduleService { fs: string, recursive = true ): Observable { - return this.getList(path, fs, recursive).pipe( + return this.getSnapshotSchedule(path, fs, recursive).pipe( map((snapList: SnapshotSchedule[]) => uniqWith( snapList.map((snapItem: SnapshotSchedule) => ({ 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 f631842919c0f..84e31efea0137 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 @@ -390,6 +390,9 @@ export class TaskMessageService { ), 'cephfs/snapshot/schedule/create': this.newTaskMessage(this.commonOperations.add, (metadata) => this.snapshotSchedule(metadata) + ), + 'cephfs/snapshot/schedule/edit': this.newTaskMessage(this.commonOperations.update, (metadata) => + this.snapshotSchedule(metadata) ) }; diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index ed2dcca25db09..181e5ecd5005d 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -1736,9 +1736,57 @@ paths: tags: - Cephfs /api/cephfs/snapshot/schedule: + 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/snapshot/schedule/{fs}: get: parameters: - - in: query + - in: path name: fs required: true schema: @@ -1772,35 +1820,35 @@ paths: - jwt: [] tags: - CephFSSnapshotSchedule - post: - parameters: [] + /api/cephfs/snapshot/schedule/{fs}/{path}: + put: + parameters: + - in: path + name: fs + required: true + schema: + type: string + - in: path + name: path + required: true + schema: + type: string requestBody: content: application/json: schema: properties: - fs: - type: string - path: - type: string - retention_policy: - type: string - snap_schedule: + retention_to_add: type: string - start: + retention_to_remove: type: string - required: - - fs - - path - - snap_schedule - - start type: object responses: - '201': + '200': content: application/vnd.ceph.api.v1.0+json: type: object - description: Resource created. + description: Resource updated. '202': content: application/vnd.ceph.api.v1.0+json: -- 2.39.5