]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Create and delete and update s3 notification in dashboard 62992/head
authorpujaoshahu <pshahu@redhat.com>
Fri, 25 Apr 2025 15:48:51 +0000 (21:18 +0530)
committerpujashahu <pshahu@redhat.com>
Wed, 30 Jul 2025 04:35:57 +0000 (10:05 +0530)
Fixes: https://tracker.ceph.com/issues/70955
Signed-off-by: pujaoshahu <pshahu@redhat.com>
16 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-notification-list/rgw-bucket-notification-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-notification-form/rgw-notification-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-topic-list/rgw-topic-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-bucket.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/rgw-topic.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/models/notification-configuration.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts
src/pybind/mgr/dashboard/services/rgw_client.py

index b45756a338c445db2b5a3a0ca026cd5bc7718d99..4605e299c9c8e87b1ed851a6c0297939e6bb7e96 100755 (executable)
@@ -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):
index b035a1a22aaaa303600473cdc1f7b3dc6d8830c2..dbbb5ecc3eaf3288ae72f0b8509ae91153d2a403 100644 (file)
             columnMode="flex"
             selectionType="single"
             identifier="Id"
+            (updateSelection)="updateSelection($event)"
             (fetchData)="fetchData()">
+  <cd-table-actions class="table-actions"
+                    [permission]="permission"
+                    [selection]="selection"
+                    [tableActions]="tableActions">
+  </cd-table-actions>
   </cd-table>
   </ng-container>
   <ng-template #filterTpl
     </ng-container>
   </ng-template>
   <ng-template #eventTpl
-               let-event="data.value">
-    <ng-container *ngIf="event">
-      <cds-tag size="sm"
-               class="badge-background-primary">
+               let-events="data.value">
+  <ng-container *ngIf="events && events.length">
+    <cds-tag *ngFor="let event of events"
+             size="sm"
+             class="badge-background-primary">
       {{ event }}
-      </cds-tag>
-    </ng-container>
-  </ng-template>
+    </cds-tag>
+  </ng-container>
+</ng-template>
+
index bd82bb0e6a0aaa71d021f3861a282fe4924c544d..8b806ba5ffe54317f822f8f76d3fcf384e896b67 100644 (file)
@@ -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<RgwBucketNotificationListComponent>;
@@ -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: ''
+        }
+      }
+    });
+  });
 });
index 5e926af2ac5552858b047c727381a139d5cc50b6..ffec0f3d3c32c29a7124afa86ef2ed342c7ae7b9 100644 (file)
@@ -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<any>;
   @ViewChild('eventTpl', { static: true })
   eventTpl: TemplateRef<any>;
-
+  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 (file)
index 0000000..2724fb7
--- /dev/null
@@ -0,0 +1,223 @@
+<cds-modal size="md"
+           [open]="open"
+           (overlaySelected)="closeModal()">
+  <cds-modal-header (closeSelect)="closeModal()"
+                    i18n>
+    {{ editing ? 'Edit' : 'Create' }} Notification configuration
+    <cd-help-text [formAllFieldsRequired]="false">All fields are optional, except where marked required.</cd-help-text>
+  </cds-modal-header>
+
+  <ng-container>
+    <section cdsModalContent>
+      <form name="notificationForm"
+            #formDir="ngForm"
+            [formGroup]="notificationForm"
+            novalidate>
+        <!-- Id-->
+        <div cdsRow
+             class="form-item form-item-append">
+          <div cdsCol>
+            <cds-text-label labelInputID="name"
+                            i18n
+                            i18n-helperText
+                            cdRequiredField="Name"
+                            [invalid]="notificationForm.controls.id.invalid && notificationForm.controls.id.dirty"
+                            [invalidText]="nameError"
+                            [helperText]="helpertext">Topic Name
+            <input cdsText
+                   type="text"
+                   placeholder="Name..."
+                   i18n-placeholder
+                   id="name"
+                   autoFocus
+                   formControlName="id"
+                   [invalid]="notificationForm.controls.id.invalid && notificationForm.controls.id.dirty"/>
+            </cds-text-label>
+            <ng-template #helpertext>
+              <span i18n>Enter a unique notification name</span>
+            </ng-template>
+            <ng-template #nameError>
+              <span *ngIf="notificationForm.showError('id', formDir, 'required')"
+                    class="invalid-feedback">
+                <ng-container i18n>This field is required.</ng-container>
+              </span>
+              <span class="invalid-feedback"
+                    *ngIf="notificationForm.showError('id', formDir, 'duplicate')"
+                    i18n>The name is already in use. Please choose a different one</span>
+            </ng-template>
+          </div>
+          <!-- Topic -->
+          <div cdsCol>
+            <cds-select id="topic"
+                        formControlName="topic"
+                        cdRequiredField="Topic"
+                        label="Topic"
+                        i18n-label
+                        placeholder="Select a topic..."
+                        i18n-placeholder
+                        [invalid]="notificationForm.controls.topic.invalid && notificationForm.controls.topic.dirty"
+                        [invalidText]="topicError"
+                        helperText="This topic will define and control the notification settings"
+                        i18n-helperText>
+            <option *ngIf="topicArn === null"
+                    value="">Loading...
+            </option>
+            <option [ngValue]="null"
+                    i18n>
+              -- Select a topic --
+            </option>
+            <option *ngFor="let data of topicArn"
+                    i18n
+                    [ngValue]="data">
+              {{ data }}
+            </option>
+          </cds-select>
+            <ng-template #topicError>
+              <span *ngIf="notificationForm.showError('topic', formDir, 'required')"
+                    class="invalid-feedback">
+                <ng-container i18n>This field is required.</ng-container>
+              </span>
+            </ng-template>
+          </div>
+        </div>
+
+        <!-- Events -->
+        <div cdsRow
+             class="form-item form-item-append">
+
+          <div cdsCol>
+            <cds-combo-box label="Event"
+                           type="multi"
+                           formControlName="event"
+                           placeholder="Select event..."
+                           [items]="eventOption"
+                           itemValueKey="content"
+                           id="event"
+                           cdDynamicInputCombobox
+                           (updatedItems)="eventOption = $event"
+                           i18n-placeholder
+                           helperText="Choose the S3 event type that will trigger the notification, such as object creation or deletion"
+                           i18n-helperText>
+              <cds-dropdown-list></cds-dropdown-list>
+            </cds-combo-box>
+          </div>
+        </div>
+        <cd-form-advanced-fieldset class="cd-header">
+        <ng-container *ngFor="let key of filterTypes">
+          <ng-container
+            *ngTemplateOutlet="
+              filterTemplate;
+              context: {
+                type: key,
+                controls: filterControls[key],
+                options: filterSettings[key].options,
+                isDropdown: filterSettings[key].isDropdown,
+                namePlaceholder: filterSettings[key].namePlaceholder,
+                valuePlaceholder: filterSettings[key].valuePlaceholder,
+                nameHelper: filterSettings[key].nameHelper,
+                valueHelper: filterSettings[key].valueHelper
+              }
+            ">
+          </ng-container>
+        </ng-container>
+      </cd-form-advanced-fieldset>
+      </form>
+    </section>
+  </ng-container>
+
+  <!-- Submit Button -->
+  <cd-form-button-panel (submitActionEvent)="onSubmit()"
+                        [form]="notificationForm"
+                        [submitText]="editing ? actionLabels.EDIT : actionLabels.CREATE"
+                        [modalForm]="true"> </cd-form-button-panel>
+  </cds-modal>
+
+
+  <ng-template #filterTemplate
+               let-type="type"
+               let-controls="controls"
+               let-options="options"
+               let-isDropdown="isDropdown"
+               let-namePlaceholder="namePlaceholder"
+               let-valuePlaceholder="valuePlaceholder"
+               let-nameHelper="nameHelper"
+               let-valueHelper="valueHelper">
+
+  <h6 i18n>{{ typeLabels[type] }}</h6>
+
+  <div [formGroup]="notificationForm.get('filter')">
+    <div [formArrayName]="type">
+      <div *ngFor="let group of controls.controls; let i = index"
+           [formGroupName]="i">
+        <div cdsRow
+             class="form-item form-item-append">
+          <div cdsCol>
+            <ng-container *ngIf="isDropdown; else textInputName">
+              <cds-select [id]="type + '-name-' + i"
+                          formControlName="Name"
+                          label="Name"
+                          i18n-label
+                          [helperText]="nameHelper"
+                          i18n-helperText>
+                <option *ngFor="let filter of s3KeyFilterValue"
+                        [value]="filter">{{ filter }}</option>
+              </cds-select>
+            </ng-container>
+
+            <ng-template #textInputName>
+              <cds-text-label [labelInputID]="type + '-name-' + i"
+                              i18n-label
+                              [helperText]="nameHelper"
+                              i18n-helperText>
+                Name
+                <input cdsText
+                       type="text"
+                       [placeholder]="namePlaceholder"
+                       formControlName="Name"
+                       i18n />
+              </cds-text-label>
+            </ng-template>
+          </div>
+
+          <div cdsCol>
+            <cds-text-label [labelInputID]="type + '-value-' + i"
+                            i18n-label
+                            [helperText]="valueHelper"
+                            i18n-helperText>
+              Value
+              <input cdsText
+                     type="text"
+                     [placeholder]="valuePlaceholder"
+                     formControlName="Value"
+                     i18n />
+            </cds-text-label>
+          </div>
+          <div cdsCol
+               [columnNumbers]="{ lg: 1, md: 1 }"
+               class="item-action-btn spacing">
+            <cds-icon-button kind="primary"
+                             size="sm"
+                             (click)="addRow(type, i)">
+              <svg cdsIcon="add"
+                   size="32"
+                   class="cds--btn__icon"></svg>
+            </cds-icon-button>
+          </div>
+
+          <div cdsCol
+               [columnNumbers]="{ lg: 1, md: 1 }"
+               class="item-action-btn spacing">
+            <cds-icon-button kind="danger"
+                             size="sm"
+                             (click)="removeRow(type, i)">
+              <svg cdsIcon="trash-can"
+                   size="32"
+                   class="cds--btn__icon"></svg>
+            </cds-icon-button>
+          </div>
+        </div>
+      </div>
+    </div>
+  </div>
+</ng-template>
+
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 (file)
index 0000000..e69de29
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 (file)
index 0000000..c8aec81
--- /dev/null
@@ -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<RgwNotificationFormComponent>;
+  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 (file)
index 0000000..c800a94
--- /dev/null
@@ -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<Topic[]> = [];
+  topicArn: string[] = [];
+  notification_id: string;
+  notificationList: TopicConfiguration[] = [];
+  filterTypes: string[] = ['s3Key', 's3Metadata', 's3Tags'];
+  typeLabels: Record<string, string> = {
+    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<string, FormArray> {
+    const controls: Record<string, FormArray> = {};
+    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<void> {
+    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<string>();
+      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();
+  }
+}
index 90a58b90254a216720aa33ca74a76e25db674f90..1fef9f21936ef532cdd755a8b6405f0fa2611ec7 100644 (file)
@@ -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';
index ee2ef900cf305ac192c14054b7ac6e5499980e5b..c7156140034dd1bc426f8db9e11f0a6c0388f8ea 100644 (file)
@@ -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]
 })
index b47b551feb37d640e5831d067db92cbde3fd56c2..5f82e30fc081ec93a8e7a236d9ff9c36c3d3d4a9 100644 (file)
@@ -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 });
+    });
+  }
 }
index 5f7f39459c938d1a5dcdf465401c38c9a2271246..1dcb2f953df940e9285af87202cbe1fd44659e32 100644 (file)
@@ -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;
index f8975ddbb6156dc7c4eb05ca306992f4eb8c25ca..6dcc48882f453d8075640a39729382e919b6e94a 100644 (file)
@@ -17,8 +17,8 @@ export class RgwTopicService extends ApiClient {
     super();
   }
 
-  listTopic(): Observable<Topic> {
-    return this.http.get<Topic>(this.baseURL);
+  listTopic(): Observable<Topic[]> {
+    return this.http.get<Topic[]>(this.baseURL);
   }
 
   getTopic(key: string) {
index 9fa51a420a16a391400975c1ebcb09a319e40a79..428b6b1630180458132a19b6faace4dd4b7811d4 100644 (file)
@@ -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`
+};
index ccd502fe21886e3a34bc66c70bb7c31e6083cc27..eeec846cfd398e2eb6b89a1a7b64fe2591092c2f 100644 (file)
@@ -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}'`;
   }
index 44210840612e8bc058dd41c5ed9e772f0b011937..d47b969f8ddd36baa2b14556c9616893155bd067 100755 (executable)
@@ -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'<Event>{escape(str(event))}</Event>\n' for event in events)
+
+        def process_filter(value):
+            xml = '<Filter>\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 += (
+                            '<FilterRule>\n'
+                            f'<Name>{escape(str(rule["Name"]))}</Name>\n'
+                            f'<Value>{escape(str(rule["Value"]))}</Value>\n'
+                            '</FilterRule>\n'
+                        )
+                    xml += f'</{filter_key}>\n'
+            xml += '</Filter>\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)}</{key}>\n'
+                            xml += f'<{tag}>\n{transform(item)}</{tag}>\n'
                     elif isinstance(value, dict):
-                        xml += f'<{key}>\n{transform(value)}</{key}>\n'
+                        xml += f'<{tag}>\n{transform(value)}</{tag}>\n'
                     else:
-                        xml += f'<{key}>{str(value)}</{key}>\n'
+                        xml += f'<{tag}>{escape(str(value))}</{tag}>\n'
 
             elif isinstance(data, list):
                 for item in data:
                     xml += transform(item)
             else:
-                xml += f'{data}'
+                xml += escape(str(data))
 
             return xml