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):
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>
+
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>;
configureTestBed({
declarations: [RgwBucketNotificationListComponent],
- imports: [ComponentsModule, HttpClientTestingModule],
+ imports: [ComponentsModule, HttpClientTestingModule, ToastrModule.forRoot()],
providers: [
{ provide: 'bucket', useValue: { bucket: 'bucket1', owner: 'dashboard' } },
{ provide: RgwBucketService, useClass: MockRgwBucketService }
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: ''
+ }
+ }
+ });
+ });
});
-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';
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({
})
export class RgwBucketNotificationListComponent extends ListWithDetails implements OnInit {
@Input() bucket: Bucket;
+ @Output() updateBucketDetails = new EventEmitter();
+ @ViewChild(TableComponent, { static: true })
table: TableComponent;
permission: Permission;
tableActions: CdTableAction[];
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();
}
}
];
+ 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(
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());
+ }
}
--- /dev/null
+<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>
+
--- /dev/null
+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();
+ });
+});
--- /dev/null
+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();
+ }
+}
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';
import { NgModule } from '@angular/core';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
import { RouterModule, Routes } from '@angular/router';
-
import {
NgbNavModule,
NgbPopoverModule,
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: [
RgwTopicListComponent,
RgwTopicDetailsComponent,
RgwTopicFormComponent,
- RgwBucketNotificationListComponent
+ RgwBucketNotificationListComponent,
+ RgwNotificationFormComponent
],
providers: [TitleCasePipe]
})
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 });
+ });
+ }
}
it('should be created', () => {
expect(service).toBeTruthy();
});
+
it('should call list with result', () => {
service.listTopic().subscribe((resp) => {
let result = resp;
super();
}
- listTopic(): Observable<Topic> {
- return this.http.get<Topic>(this.baseURL);
+ listTopic(): Observable<Topic[]> {
+ return this.http.get<Topic[]>(this.baseURL);
}
getTopic(key: string) {
+import { ComboBoxItem } from './combo-box.model';
+
+export interface NotificationConfig {
+ NotificationConfiguration: NotificationConfiguration;
+}
+
export interface NotificationConfiguration {
TopicConfiguration: TopicConfiguration[];
}
}
export interface Filter {
- Key: Key;
- Metadata: Metadata;
- Tags: Tags;
+ S3Key: Key;
+ S3Metadata: Metadata;
+ S3Tags: Tags;
}
export interface Key {
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`
+};
'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]}'`}`;
}),
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}'`;
}
# pylint: disable=C0302
# pylint: disable=too-many-branches
# pylint: disable=too-many-lines
+
import ipaddress
import json
import logging
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.")
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