From: pujaoshahu Date: Fri, 25 Apr 2025 15:48:51 +0000 (+0530) Subject: mgr/dashboard: Create and delete and update s3 notification in dashboard X-Git-Url: http://git.apps.os.sepia.ceph.com/?a=commitdiff_plain;h=a4155c27911f5ea62c8d93d4eddd41d06b04dd5b;p=ceph.git mgr/dashboard: Create and delete and update s3 notification in dashboard Fixes: https://tracker.ceph.com/issues/70955 Signed-off-by: pujaoshahu --- diff --git a/src/pybind/mgr/dashboard/controllers/rgw.py b/src/pybind/mgr/dashboard/controllers/rgw.py index b45756a338c44..4605e299c9c8e 100755 --- a/src/pybind/mgr/dashboard/controllers/rgw.py +++ b/src/pybind/mgr/dashboard/controllers/rgw.py @@ -775,6 +775,7 @@ class RgwBucket(RgwRESTController): return self._get_notification(bucket_name, daemon_name, owner) @RESTController.Collection(method='PUT', path='/notification') + @allow_empty_body @EndpointDoc("Create or update the bucket notification") def set_notification(self, bucket_name: str, notification: str = '', daemon_name=None, owner=None): diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.html index b035a1a22aaaa..dbbb5ecc3eaf3 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.html @@ -14,7 +14,13 @@ columnMode="flex" selectionType="single" identifier="Id" + (updateSelection)="updateSelection($event)" (fetchData)="fetchData()"> + + - - + let-events="data.value"> + + {{ event }} - - - + + + + diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.spec.ts index bd82bb0e6a0aa..8b806ba5ffe54 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.spec.ts @@ -1,14 +1,16 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { HttpClientTestingModule } from '@angular/common/http/testing'; import { RgwBucketNotificationListComponent } from './rgw-bucket-notification-list.component'; -import { configureTestBed } from '~/testing/unit-test-helper'; +import { configureTestBed, PermissionHelper } from '~/testing/unit-test-helper'; import { ComponentsModule } from '~/app/shared/components/components.module'; import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; import { of } from 'rxjs'; +import { ToastrModule } from 'ngx-toastr'; class MockRgwBucketService { listNotification = jest.fn((bucket: string) => of([{ bucket, notifications: [] }])); } + describe('RgwBucketNotificationListComponent', () => { let component: RgwBucketNotificationListComponent; let fixture: ComponentFixture; @@ -17,7 +19,7 @@ describe('RgwBucketNotificationListComponent', () => { configureTestBed({ declarations: [RgwBucketNotificationListComponent], - imports: [ComponentsModule, HttpClientTestingModule], + imports: [ComponentsModule, HttpClientTestingModule, ToastrModule.forRoot()], providers: [ { provide: 'bucket', useValue: { bucket: 'bucket1', owner: 'dashboard' } }, { provide: RgwBucketService, useClass: MockRgwBucketService } @@ -30,18 +32,96 @@ describe('RgwBucketNotificationListComponent', () => { rgwtbucketService = TestBed.inject(RgwBucketService); rgwnotificationListSpy = spyOn(rgwtbucketService, 'listNotification').and.callThrough(); - fixture = TestBed.createComponent(RgwBucketNotificationListComponent); - component = fixture.componentInstance; fixture.detectChanges(); }); it('should create', () => { expect(component).toBeTruthy(); }); + it('should call list', () => { rgwtbucketService.listNotification('testbucket').subscribe((response) => { expect(response).toEqual([{ bucket: 'testbucket', notifications: [] }]); }); expect(rgwnotificationListSpy).toHaveBeenCalledWith('testbucket'); }); + + it('should test all TableActions combinations', () => { + const permissionHelper = new PermissionHelper(component.permission); + const tableActions = permissionHelper.setPermissionsAndGetActions(component.tableActions); + expect(tableActions).toEqual({ + 'create,update,delete': { + actions: ['Create', 'Edit', 'Delete'], + primary: { + multiple: 'Create', + executing: 'Create', + single: 'Create', + no: 'Create' + } + }, + 'create,update': { + actions: ['Create', 'Edit'], + primary: { + multiple: 'Create', + executing: 'Create', + single: 'Create', + no: 'Create' + } + }, + 'create,delete': { + actions: ['Create', 'Delete'], + primary: { + multiple: 'Create', + executing: 'Create', + single: 'Create', + no: 'Create' + } + }, + create: { + actions: ['Create'], + primary: { + multiple: 'Create', + executing: 'Create', + single: 'Create', + no: 'Create' + } + }, + 'update,delete': { + actions: ['Edit', 'Delete'], + primary: { + multiple: '', + executing: '', + single: '', + no: '' + } + }, + update: { + actions: ['Edit'], + primary: { + multiple: 'Edit', + executing: 'Edit', + single: 'Edit', + no: 'Edit' + } + }, + delete: { + actions: ['Delete'], + primary: { + multiple: 'Delete', + executing: 'Delete', + single: 'Delete', + no: 'Delete' + } + }, + 'no-permissions': { + actions: [], + primary: { + multiple: '', + executing: '', + single: '', + no: '' + } + } + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.ts index 5e926af2ac555..ffec0f3d3c32c 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.ts @@ -1,4 +1,12 @@ -import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core'; +import { + Component, + EventEmitter, + Input, + OnInit, + Output, + TemplateRef, + ViewChild +} from '@angular/core'; import { BehaviorSubject, Observable, of } from 'rxjs'; import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; @@ -11,10 +19,16 @@ import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { Bucket } from '../models/rgw-bucket'; import { CdTableFetchDataContext } from '~/app/shared/models/cd-table-fetch-data-context'; +import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; import { catchError, switchMap } from 'rxjs/operators'; -import { TopicConfiguration } from '~/app/shared/models/notification-configuration.model'; +import { ModalCdsService } from '~/app/shared/services/modal-cds.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; import { ListWithDetails } from '~/app/shared/classes/list-with-details.class'; -import { CellTemplate } from '~/app/shared/enum/cell-template.enum'; +import { TopicConfiguration } from '~/app/shared/models/notification-configuration.model'; +import { RgwNotificationFormComponent } from '../rgw-notification-form/rgw-notification-form.component'; +import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { Icons } from '~/app/shared/enum/icons.enum'; const BASE_URL = 'rgw/bucket'; @Component({ @@ -25,6 +39,8 @@ const BASE_URL = 'rgw/bucket'; }) export class RgwBucketNotificationListComponent extends ListWithDetails implements OnInit { @Input() bucket: Bucket; + @Output() updateBucketDetails = new EventEmitter(); + @ViewChild(TableComponent, { static: true }) table: TableComponent; permission: Permission; tableActions: CdTableAction[]; @@ -37,11 +53,13 @@ export class RgwBucketNotificationListComponent extends ListWithDetails implemen filterTpl: TemplateRef; @ViewChild('eventTpl', { static: true }) eventTpl: TemplateRef; - + modalRef: any; constructor( private rgwBucketService: RgwBucketService, private authStorageService: AuthStorageService, - public actionLabels: ActionLabelsI18n + public actionLabels: ActionLabelsI18n, + private modalService: ModalCdsService, + private notificationService: NotificationService ) { super(); } @@ -74,6 +92,28 @@ export class RgwBucketNotificationListComponent extends ListWithDetails implemen } ]; + const createAction: CdTableAction = { + permission: 'create', + icon: Icons.add, + click: () => this.openNotificationModal(this.actionLabels.CREATE), + name: this.actionLabels.CREATE + }; + const editAction: CdTableAction = { + permission: 'update', + icon: Icons.edit, + disable: () => this.selection.hasMultiSelection, + click: () => this.openNotificationModal(this.actionLabels.EDIT), + name: this.actionLabels.EDIT + }; + const deleteAction: CdTableAction = { + permission: 'delete', + icon: Icons.destroy, + click: () => this.deleteAction(), + disable: () => !this.selection.hasSelection, + name: this.actionLabels.DELETE, + canBePrimary: (selection: CdTableSelection) => selection.hasMultiSelection + }; + this.tableActions = [createAction, editAction, deleteAction]; this.notification$ = this.subject.pipe( switchMap(() => this.rgwBucketService.listNotification(this.bucket.bucket).pipe( @@ -89,4 +129,48 @@ export class RgwBucketNotificationListComponent extends ListWithDetails implemen fetchData() { this.subject.next([]); } + + openNotificationModal(type: string) { + const modalRef = this.modalService.show(RgwNotificationFormComponent, { + bucket: this.bucket, + selectedNotification: this.selection.first(), + editing: type === this.actionLabels.EDIT ? true : false + }); + modalRef?.close?.subscribe(() => this.updateBucketDetails.emit()); + } + + updateSelection(selection: CdTableSelection) { + this.selection = selection; + } + + deleteAction() { + const selectedNotificationId = this.selection.selected.map((notification) => notification.Id); + this.modalRef = this.modalService.show(DeleteConfirmationModalComponent, { + itemDescription: $localize`Notification`, + itemNames: selectedNotificationId, + actionDescription: $localize`delete`, + submitAction: () => this.submitDeleteNotifications(selectedNotificationId) + }); + } + + submitDeleteNotifications(notificationId: string[]) { + this.rgwBucketService + .deleteNotification(this.bucket.bucket, notificationId.join(',')) + .subscribe({ + next: () => { + this.notificationService.show( + NotificationType.success, + $localize`Notifications deleted successfully.` + ); + this.modalService.dismissAll(); + }, + error: () => { + this.notificationService.show( + NotificationType.success, + $localize`Failed to delete notifications. Please try again.` + ); + } + }); + this.modalRef?.close?.subscribe(() => this.updateBucketDetails.emit()); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.html new file mode 100644 index 0000000000000..2724fb70006cd --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.html @@ -0,0 +1,223 @@ + + + {{ editing ? 'Edit' : 'Create' }} Notification configuration + All fields are optional, except where marked required. + + + +
+
+ +
+
+ Topic Name + + + + Enter a unique notification name + + + + This field is required. + + The name is already in use. Please choose a different one + +
+ +
+ + + + + + + + This field is required. + + +
+
+ + +
+ +
+ + + +
+
+ + + + + + +
+
+
+ + + +
+ + + + +
{{ typeLabels[type] }}
+ +
+
+
+
+
+ + + + + + + + + Name + + + +
+ +
+ + Value + + +
+
+ + + +
+ +
+ + + +
+
+
+
+
+
+ diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.scss new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.spec.ts new file mode 100644 index 0000000000000..c8aec819e7465 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.spec.ts @@ -0,0 +1,84 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RgwNotificationFormComponent } from './rgw-notification-form.component'; +import { CdLabelComponent } from '~/app/shared/components/cd-label/cd-label.component'; +import { ReactiveFormsModule } from '@angular/forms'; +import { ToastrModule } from 'ngx-toastr'; +import { ComponentsModule } from '~/app/shared/components/components.module'; +import { + InputModule, + ModalModule, + ModalService, + NumberModule, + RadioModule, + SelectModule, + ComboBoxModule +} from 'carbon-components-angular'; +import { RouterTestingModule } from '@angular/router/testing'; +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { of } from 'rxjs'; +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { NO_ERRORS_SCHEMA } from '@angular/core'; + +class MockRgwBucketService { + setNotification = jest.fn().mockReturnValue(of(null)); + getBucketNotificationList = jest.fn().mockReturnValue(of(null)); + listNotification = jest.fn().mockReturnValue(of([])); +} + +describe('RgwNotificationFormComponent', () => { + let component: RgwNotificationFormComponent; + let fixture: ComponentFixture; + let rgwBucketService: MockRgwBucketService; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [RgwNotificationFormComponent, CdLabelComponent], + imports: [ + ReactiveFormsModule, + HttpClientTestingModule, + RadioModule, + SelectModule, + NumberModule, + InputModule, + ToastrModule.forRoot(), + ComponentsModule, + ModalModule, + ComboBoxModule, + ReactiveFormsModule, + ComponentsModule, + InputModule, + RouterTestingModule + ], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + ModalService, + { provide: 'bucket', useValue: { bucket: 'bucket1', owner: 'dashboard' } }, + { provide: RgwBucketService, useClass: MockRgwBucketService } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(RgwNotificationFormComponent); + component = fixture.componentInstance; + rgwBucketService = (TestBed.inject(RgwBucketService) as unknown) as MockRgwBucketService; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + it('should call setNotification when submitting form', () => { + rgwBucketService = (TestBed.inject(RgwBucketService) as unknown) as MockRgwBucketService; + component['notificationList'] = []; + component.notificationForm.patchValue({ + id: 'notif-1', + topic: 'arn:aws:sns:us-east-1:123456789012:MyTopic', + event: ['PutObject'] + }); + + component.notificationForm.get('filter.s3Key')?.setValue([{ Name: 'prefix', Value: 'logs/' }]); + component.notificationForm.get('filter.s3Metadata')?.setValue([{ Name: '', Value: '' }]); + component.notificationForm.get('filter.s3Tags')?.setValue([{ Name: '', Value: '' }]); + component.onSubmit(); + expect(rgwBucketService.setNotification).toHaveBeenCalled(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.ts new file mode 100644 index 0000000000000..c800a948ad143 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.ts @@ -0,0 +1,310 @@ +import { Component, Inject, OnInit, Optional, ChangeDetectorRef } from '@angular/core'; +import { + FormArray, + Validators, + AbstractControl, + FormGroup, + ValidationErrors +} from '@angular/forms'; +import { ActionLabelsI18n } from '~/app/shared/constants/app.constants'; +import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder'; +import { CdFormGroup } from '~/app/shared/forms/cd-form-group'; +import { CdForm } from '~/app/shared/forms/cd-form'; +import { ComboBoxItem } from '~/app/shared/models/combo-box.model'; +import { Topic } from '~/app/shared/models/topic.model'; +import { Bucket } from '../models/rgw-bucket'; +import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service'; +import { NotificationService } from '~/app/shared/services/notification.service'; +import { Router } from '@angular/router'; +import { NotificationType } from '~/app/shared/enum/notification-type.enum'; +import { RgwTopicService } from '~/app/shared/api/rgw-topic.service'; +import { + events, + FilterRules, + s3KeyFilter, + s3KeyFilterTexts, + s3MetadataFilterTexts, + s3TagsFilterTexts, + TopicConfiguration +} from '~/app/shared/models/notification-configuration.model'; + +@Component({ + selector: 'cd-rgw-notification-form', + templateUrl: './rgw-notification-form.component.html', + styleUrls: ['./rgw-notification-form.component.scss'] +}) +export class RgwNotificationFormComponent extends CdForm implements OnInit { + notificationForm: CdFormGroup; + eventOption: ComboBoxItem[] = events; + s3KeyFilterValue: string[] = []; + topics: Partial = []; + topicArn: string[] = []; + notification_id: string; + notificationList: TopicConfiguration[] = []; + filterTypes: string[] = ['s3Key', 's3Metadata', 's3Tags']; + typeLabels: Record = { + s3Key: 'S3 Key configuration', + s3Metadata: 'S3 Metadata configuration', + s3Tags: 'S3 Tags configuration' + }; + + filterSettings: Record< + string, + { + options: string[] | null; + isDropdown: boolean; + namePlaceholder: string; + valuePlaceholder: string; + nameHelper: string; + valueHelper: string; + } + > = { + s3Key: { + options: null, + isDropdown: true, + namePlaceholder: s3KeyFilterTexts.namePlaceholder, + valuePlaceholder: s3KeyFilterTexts.valuePlaceholder, + nameHelper: s3KeyFilterTexts.nameHelper, + valueHelper: s3KeyFilterTexts.valueHelper + }, + s3Metadata: { + options: null, + isDropdown: false, + namePlaceholder: s3MetadataFilterTexts.namePlaceholder, + valuePlaceholder: s3MetadataFilterTexts.valuePlaceholder, + nameHelper: s3MetadataFilterTexts.nameHelper, + valueHelper: s3MetadataFilterTexts.valueHelper + }, + s3Tags: { + options: null, + isDropdown: false, + namePlaceholder: s3TagsFilterTexts.namePlaceholder, + valuePlaceholder: s3TagsFilterTexts.valuePlaceholder, + nameHelper: s3TagsFilterTexts.nameHelper, + valueHelper: s3TagsFilterTexts.valueHelper + } + }; + + get filterControls(): Record { + const controls: Record = {}; + this.filterTypes.forEach((type) => { + controls[type] = this.getFormArray(type); + }); + return controls; + } + + constructor( + @Inject('bucket') public bucket: Bucket, + @Optional() @Inject('selectedNotification') public selectedNotification: TopicConfiguration, + @Optional() @Inject('editing') public editing = false, + public actionLabels: ActionLabelsI18n, + private rgwBucketService: RgwBucketService, + private rgwTopicService: RgwTopicService, + private notificationService: NotificationService, + private fb: CdFormBuilder, + private router: Router, + private cdRef: ChangeDetectorRef + ) { + super(); + } + + ngOnInit() { + this.editing = !!this.selectedNotification; + this.s3KeyFilterValue = Object.values(s3KeyFilter); + this.filterSettings.s3Key.options = this.s3KeyFilterValue; + this.createNotificationForm(); + this.rgwBucketService.listNotification(this.bucket.bucket).subscribe({ + next: (notificationList: TopicConfiguration[]) => { + this.notificationList = notificationList; + + this.getTopicName().then(() => { + if (this.editing && this.selectedNotification) { + this.notification_id = this.selectedNotification.Id; + this.notificationForm.get('id').disable(); + this.patchNotificationForm(this.selectedNotification); + } + }); + } + }); + } + + getTopicName(): Promise { + return new Promise((resolve, reject) => { + this.rgwTopicService.listTopic().subscribe({ + next: (topics: Topic[]) => { + this.topics = topics; + this.topicArn = topics.map((topic: Topic) => topic.arn); + resolve(); + }, + error: (err) => reject(err) + }); + }); + } + + patchNotificationForm(config: any): void { + this.cdRef.detectChanges(); + this.notificationForm.patchValue({ + id: config.Id, + topic: typeof config.Topic === 'object' ? config.Topic.arn : config.Topic, + event: config.Event + }); + + this.setFilterRules('s3Key', config.Filter?.S3Key?.FilterRule); + this.setFilterRules('s3Metadata', config.Filter?.S3Metadata?.FilterRule); + this.setFilterRules('s3Tags', config.Filter?.S3Tags?.FilterRule); + } + + setFilterRules(type: string, rules: FilterRules[] = []): void { + let formArray = this.getFormArray(type); + if (!formArray) { + const filterGroup = this.notificationForm.get('filter') as FormGroup; + filterGroup.setControl(type, this.fb.array([])); + formArray = this.getFormArray(type); + } + + formArray.clear(); + + if (!rules || rules.length === 0) { + formArray.push(this.createNameValueGroup()); + return; + } + + rules.forEach((rule) => { + formArray.push(this.fb.group({ Name: [rule.Name], Value: [rule.Value] })); + }); + } + + createNotificationForm() { + this.notificationForm = this.fb.group({ + id: [null, [Validators.required, this.duplicateNotificationId.bind(this)]], + topic: [null, [Validators.required]], + event: [[], []], + filter: this.fb.group({ + s3Key: this.fb.array([this.createNameValueGroup()]), + s3Metadata: this.fb.array([this.createNameValueGroup()]), + s3Tags: this.fb.array([this.createNameValueGroup()]) + }) + }); + } + + duplicateNotificationId(control: AbstractControl): ValidationErrors | null { + const currentId = control.value?.trim(); + if (!currentId) return null; + if (Array.isArray(this.notificationList)) { + const duplicateFound = this.notificationList.some( + (notification: TopicConfiguration) => notification.Id === currentId + ); + + return duplicateFound ? { duplicate: true } : null; + } + + return null; + } + + private createNameValueGroup(): CdFormGroup { + return this.fb.group({ + Name: [null], + Value: [null] + }); + } + + getFormArray(arrayName: string): FormArray { + const filterGroup = this.notificationForm.get('filter') as FormGroup; + return filterGroup?.get(arrayName) as FormArray; + } + + getFiltersControls(type: string): FormArray { + return this.getFormArray(type); + } + + addRow(arrayName: string, index: number): void { + const array = this.getFormArray(arrayName); + array.insert(index + 1, this.createNameValueGroup()); + } + + removeRow(arrayName: string, index: number): void { + const formArray = this.getFormArray(arrayName); + if (formArray && formArray.length > 1 && index >= 0 && index < formArray.length) { + formArray.removeAt(index); + } else if (formArray.length === 1) { + const group = formArray.at(0) as FormGroup; + group.reset(); + } + + this.cdRef.detectChanges(); + } + + showInvalid(field: string): boolean { + const control: AbstractControl | null = this.notificationForm.get(field); + return control?.invalid && (control.dirty || control.touched); + } + + onSubmit() { + if (!this.notificationForm.valid) { + this.notificationForm.markAllAsTouched(); + this.notificationForm.setErrors({ cdSubmitButton: true }); + return; + } + + const formValue = this.notificationForm.getRawValue(); + const buildRules = (rules: FilterRules[]) => { + const seen = new Set(); + return ( + rules + ?.filter((item) => item.Name && item.Value) + .filter((item) => { + if (seen.has(item.Name)) return false; + seen.add(item.Name); + return true; + }) || [] + ); + }; + + const successMessage = this.editing + ? $localize`Bucket notification updated successfully` + : $localize`Bucket notification created successfully`; + + const notificationConfiguration = { + TopicConfiguration: { + Id: formValue.id, + Topic: formValue.topic, + Event: formValue.event, + Filter: { + S3Key: { + FilterRules: buildRules(formValue.filter?.s3Key) + }, + S3Metadata: { + FilterRules: buildRules(formValue.filter?.s3Metadata) + }, + S3Tags: { + FilterRules: buildRules(formValue.filter?.s3Tags) + } + } + } + }; + + this.rgwBucketService + .setNotification( + this.bucket.bucket, + JSON.stringify(notificationConfiguration), + this.bucket.owner + ) + .subscribe({ + next: () => { + this.notificationService.show(NotificationType.success, successMessage); + }, + error: (error: any) => { + this.notificationService.show(NotificationType.error, error.message); + this.notificationForm.setErrors({ cdSubmitButton: true }); + }, + complete: () => { + this.closeModal(); + } + }); + } + + goToCreateNotification() { + this.router.navigate(['rgw/notification/create']); + this.closeModal(); + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts index 90a58b90254a2..1fef9f21936ef 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts @@ -11,7 +11,6 @@ import { Permission } from '~/app/shared/models/permissions'; import { AuthStorageService } from '~/app/shared/services/auth-storage.service'; import { RgwTopicService } from '~/app/shared/api/rgw-topic.service'; - import { CdTableSelection } from '~/app/shared/models/cd-table-selection'; import { URLBuilderService } from '~/app/shared/services/url-builder.service'; import { Icons } from '~/app/shared/enum/icons.enum'; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts index ee2ef900cf305..c7156140034dd 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts @@ -2,7 +2,6 @@ import { CommonModule, TitleCasePipe } from '@angular/common'; import { NgModule } from '@angular/core'; import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { RouterModule, Routes } from '@angular/router'; - import { NgbNavModule, NgbPopoverModule, @@ -114,6 +113,7 @@ import { RgwTopicListComponent } from './rgw-topic-list/rgw-topic-list.component import { RgwTopicDetailsComponent } from './rgw-topic-details/rgw-topic-details.component'; import { RgwTopicFormComponent } from './rgw-topic-form/rgw-topic-form.component'; import { RgwBucketNotificationListComponent } from './rgw-bucket-notification-list/rgw-bucket-notification-list.component'; +import { RgwNotificationFormComponent } from './rgw-notification-form/rgw-notification-form.component'; @NgModule({ imports: [ @@ -216,7 +216,8 @@ import { RgwBucketNotificationListComponent } from './rgw-bucket-notification-li RgwTopicListComponent, RgwTopicDetailsComponent, RgwTopicFormComponent, - RgwBucketNotificationListComponent + RgwBucketNotificationListComponent, + RgwNotificationFormComponent ], providers: [TitleCasePipe] }) diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts index b47b551feb37d..5f82e30fc081e 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts @@ -301,4 +301,14 @@ export class RgwBucketService extends ApiClient { return this.http.put(`${this.url}/notification`, null, { params: params }); }); } + + deleteNotification(bucket_name: string, notification_id: string) { + return this.rgwDaemonService.request((params: HttpParams) => { + params = params.appendAll({ + bucket_name: bucket_name, + notification_id: notification_id + }); + return this.http.delete(`${this.url}/notification`, { params }); + }); + } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.spec.ts index 5f7f39459c938..1dcb2f953df94 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.spec.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.spec.ts @@ -23,6 +23,7 @@ describe('RgwTopicService', () => { it('should be created', () => { expect(service).toBeTruthy(); }); + it('should call list with result', () => { service.listTopic().subscribe((resp) => { let result = resp; diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts index f8975ddbb6156..6dcc48882f453 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts @@ -17,8 +17,8 @@ export class RgwTopicService extends ApiClient { super(); } - listTopic(): Observable { - return this.http.get(this.baseURL); + listTopic(): Observable { + return this.http.get(this.baseURL); } getTopic(key: string) { diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-configuration.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-configuration.model.ts index 9fa51a420a16a..428b6b1630180 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-configuration.model.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-configuration.model.ts @@ -1,3 +1,9 @@ +import { ComboBoxItem } from './combo-box.model'; + +export interface NotificationConfig { + NotificationConfiguration: NotificationConfiguration; +} + export interface NotificationConfiguration { TopicConfiguration: TopicConfiguration[]; } @@ -10,9 +16,9 @@ export interface TopicConfiguration { } export interface Filter { - Key: Key; - Metadata: Metadata; - Tags: Tags; + S3Key: Key; + S3Metadata: Metadata; + S3Tags: Tags; } export interface Key { @@ -28,3 +34,44 @@ export interface FilterRules { Name: string; Value: string; } + +export const events: ComboBoxItem[] = [ + { content: 's3:ObjectCreated:*', name: 's3:ObjectCreated:*' }, + { content: 's3:ObjectCreated:Put', name: 's3:ObjectCreated:Put' }, + { content: 's3:ObjectCreated:Copy', name: 's3:ObjectCreated:Copy' }, + { + content: 's3:ObjectCreated:CompleteMultipartUpload', + name: 's3:ObjectCreated:CompleteMultipartUpload' + }, + { content: 's3:ObjectRemoved:*', name: 's3:ObjectRemoved:*' }, + { content: 's3:ObjectRemoved:Delete', name: 's3:ObjectRemoved:Delete' }, + { content: 's3:ObjectRemoved:DeleteMarkerCreated', name: 's3:ObjectRemoved:DeleteMarkerCreated' } +]; + +export enum s3KeyFilter { + SELECT = '-- Select key filter type --', + PREFIX = 'prefix', + SUFFIX = 'suffix', + REGEX = 'regex' +} + +export const s3KeyFilterTexts = { + namePlaceholder: $localize`e.g. images/`, + valuePlaceholder: $localize`e.g. .jpg`, + nameHelper: $localize`Choose a filter type (prefix or suffix) to specify which object keys trigger the notification`, + valueHelper: $localize`Enter the prefix (e.g. images/) or suffix (e.g. .jpg) value for the S3 key filter` +}; + +export const s3MetadataFilterTexts = { + namePlaceholder: $localize`x-amz-meta-xxx...`, + valuePlaceholder: $localize`e.g. my-custom-value`, + nameHelper: $localize`Enter a metadata key name to identify the custom information`, + valueHelper: $localize`Enter the metadata value that corresponds to the key` +}; + +export const s3TagsFilterTexts = { + namePlaceholder: $localize`e.g. backup-status`, + valuePlaceholder: $localize`e.g. completed`, + nameHelper: $localize`Enter a tag key to categorize the S3 objects`, + valueHelper: $localize`Enter the tag value that corresponds to the key` +}; 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 ccd502fe21886..eeec846cfd398 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 @@ -325,6 +325,12 @@ export class TaskMessageService { 'rgw/bucket/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => { return $localize`${metadata.bucket_names[0]}`; }), + 'rgw/bucket/notification/delete': this.newTaskMessage( + this.commonOperations.delete, + (metadata) => { + return $localize`${metadata.notification_id[0]}`; + } + ), 'rgw/accounts': this.newTaskMessage(this.commonOperations.delete, (metadata) => { return $localize`${`account '${metadata.account_names[0]}'`}`; }), @@ -621,6 +627,9 @@ export class TaskMessageService { topic(metadata: any) { return $localize`Topic '${metadata.name}'`; } + notification(metadata: any) { + return $localize`Notification '${metadata.name}'`; + } service(metadata: any) { return $localize`service '${metadata.service_name}'`; } diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py index 44210840612e8..d47b969f8ddd3 100755 --- a/src/pybind/mgr/dashboard/services/rgw_client.py +++ b/src/pybind/mgr/dashboard/services/rgw_client.py @@ -2,6 +2,7 @@ # pylint: disable=C0302 # pylint: disable=too-many-branches # pylint: disable=too-many-lines + import ipaddress import json import logging @@ -14,11 +15,12 @@ from collections import defaultdict from enum import Enum from subprocess import SubprocessError from urllib.parse import urlparse, urlunparse +from xml.sax.saxutils import escape import requests try: - import xmltodict + import xmltodict # type: ignore except ModuleNotFoundError: logging.error("Module 'xmltodict' is not installed.") @@ -770,25 +772,53 @@ class RgwClient(RestClient): except json.JSONDecodeError: raise DashboardException('Could not load json string') + def process_event(value): + events = value if isinstance(value, list) else [value] + return ''.join(f'{escape(str(event))}\n' for event in events) + + def process_filter(value): + xml = '\n' + for filter_key in ['S3Key', 'S3Metadata', 'S3Tags']: + rules = value.get(filter_key, {}).get('FilterRules', []) + if rules: + xml += f'<{filter_key}>\n' + for rule in rules: + xml += ( + '\n' + f'{escape(str(rule["Name"]))}\n' + f'{escape(str(rule["Value"]))}\n' + '\n' + ) + xml += f'\n' + xml += '\n' + return xml + def transform(data): - xml: str = '' + xml = '' if isinstance(data, dict): for key, value in data.items(): + if key == 'Event': + xml += process_event(value) + continue + if key == 'Filter': + xml += process_filter(value) + continue + + tag = 'Rule' if key == 'Rules' else key + if isinstance(value, list): for item in value: - if key == 'Rules': - key = 'Rule' - xml += f'<{key}>\n{transform(item)}\n' + xml += f'<{tag}>\n{transform(item)}\n' elif isinstance(value, dict): - xml += f'<{key}>\n{transform(value)}\n' + xml += f'<{tag}>\n{transform(value)}\n' else: - xml += f'<{key}>{str(value)}\n' + xml += f'<{tag}>{escape(str(value))}\n' elif isinstance(data, list): for item in data: xml += transform(item) else: - xml += f'{data}' + xml += escape(str(data)) return xml