]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Rgw ratelimit feature for user and bucket 61225/head
authorAchintk1491 <achintk1491@gmail.com>
Fri, 13 Dec 2024 07:38:33 +0000 (13:08 +0530)
committerAchintk1491 <achintk1491@gmail.com>
Wed, 19 Feb 2025 13:12:27 +0000 (18:42 +0530)
Fixes: https://tracker.ceph.com/issues/69233
Signed-off-by: Achint Kaur<ackaur@redhat.com>
Signed-off-by: Achintk1491 <achintk1491@gmail.com>
34 files changed:
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-rate-limit.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-bucket-form/rgw-bucket-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-details/rgw-user-details.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-user-form/rgw-user-form.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-user.service.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-minute.directive.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-minute.directive.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/directives/directives.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-minute.pipe.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/pipes.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/services/formatter.service.ts
src/pybind/mgr/dashboard/openapi.yaml [changed mode: 0644->0755]
src/pybind/mgr/dashboard/services/rgw_client.py
src/pybind/mgr/dashboard/tests/test_rgw.py

index 1a694b4734146085308136cd93b6b751b4584095..2cdcf7124f1ec7f727ad7f3fd52375ed47a5f618 100755 (executable)
@@ -15,7 +15,7 @@ from ..security import Permission, Scope
 from ..services.auth import AuthManager, JwtManager
 from ..services.ceph_service import CephService
 from ..services.rgw_client import _SYNC_GROUP_ID, NoRgwDaemonsException, \
-    RgwClient, RgwMultisite, RgwMultisiteAutomation
+    RgwClient, RgwMultisite, RgwMultisiteAutomation, RgwRateLimit
 from ..services.rgw_iam import RgwAccounts
 from ..services.service import RgwServiceManager, wait_for_daemon_to_start
 from ..tools import json_str_to_object, str_to_bool
@@ -722,6 +722,31 @@ class RgwBucket(RgwRESTController):
     def get_lifecycle_policy(self, bucket_name: str = '', daemon_name=None, owner=None):
         return self._get_lifecycle(bucket_name, daemon_name, owner)
 
+    @Endpoint(method='GET', path='/ratelimit')
+    @EndpointDoc("Get the bucket global rate limit")
+    @ReadPermission
+    def get_global_rate_limit(self):
+        rgwBucketRateLimit_instance = RgwRateLimit()
+        return rgwBucketRateLimit_instance.get_global_rateLimit()
+
+    @Endpoint(method='GET', path='{uid}/ratelimit')
+    @EndpointDoc("Get the bucket rate limit")
+    @ReadPermission
+    def get_rate_limit(self, uid: str):
+        rgwBucketRateLimit_instance = RgwRateLimit()
+        return rgwBucketRateLimit_instance.get_rateLimit('bucket', uid)
+
+    @Endpoint(method='PUT', path='{uid}/ratelimit')
+    @UpdatePermission
+    @allow_empty_body
+    @EndpointDoc("Update the bucket rate limit")
+    def set_rate_limit(self, enabled: bool, uid: str, max_read_ops: int,
+                       max_write_ops: int, max_read_bytes: int, max_write_bytes: int):
+        rgwBucketRateLimit_instance = RgwRateLimit()
+        return rgwBucketRateLimit_instance.set_rateLimit('bucket', enabled, uid,
+                                                         max_read_ops, max_write_ops,
+                                                         max_read_bytes, max_write_bytes)
+
 
 @UIRouter('/rgw/bucket', Scope.RGW)
 class RgwBucketUi(RgwBucket):
@@ -964,6 +989,31 @@ class RgwUser(RgwRESTController):
             'purge-keys': purge_keys
         }, json_response=False)
 
+    @Endpoint(method='GET', path='/ratelimit')
+    @EndpointDoc("Get the user global rate limit")
+    @ReadPermission
+    def get_global_rate_limit(self):
+        rgwUserRateLimit_instance = RgwRateLimit()
+        return rgwUserRateLimit_instance.get_global_rateLimit()
+
+    @Endpoint(method='GET', path='{uid}/ratelimit')
+    @EndpointDoc("Get the user rate limit")
+    @ReadPermission
+    def get_rate_limit(self, uid: str):
+        rgwUserRateLimit_instance = RgwRateLimit()
+        return rgwUserRateLimit_instance.get_rateLimit('user', uid)
+
+    @Endpoint(method='PUT', path='{uid}/ratelimit')
+    @UpdatePermission
+    @allow_empty_body
+    @EndpointDoc("Update the user rate limit")
+    def set_rate_limit(self, uid: str, enabled: bool = False, max_read_ops: int = 0,
+                       max_write_ops: int = 0, max_read_bytes: int = 0, max_write_bytes: int = 0):
+        rgwUserRateLimit_instance = RgwRateLimit()
+        return rgwUserRateLimit_instance.set_rateLimit('user', enabled,
+                                                       uid, max_read_ops, max_write_ops,
+                                                       max_read_bytes, max_write_bytes)
+
 
 class RGWRoleEndpoints:
     @staticmethod
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-rate-limit.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/models/rgw-rate-limit.ts
new file mode 100644 (file)
index 0000000..668f00a
--- /dev/null
@@ -0,0 +1,31 @@
+export interface RgwRateLimitConfig {
+  enabled: boolean;
+  name?: string;
+  max_read_ops: number;
+  max_write_ops: number;
+  max_read_bytes: number;
+  max_write_bytes: number;
+}
+export interface GlobalRateLimitConfig {
+  bucket_ratelimit: {
+    max_read_ops: number;
+    max_write_ops: number;
+    max_read_bytes: number;
+    max_write_bytes: number;
+    enabled: boolean;
+  };
+  user_ratelimit: {
+    max_read_ops: 1024;
+    max_write_ops: number;
+    max_read_bytes: number;
+    max_write_bytes: number;
+    enabled: boolean;
+  };
+  anonymous_ratelimit: {
+    max_read_ops: number;
+    max_write_ops: number;
+    max_read_bytes: number;
+    max_write_bytes: number;
+    enabled: boolean;
+  };
+}
index 1a422e5396f5c66e1d74702af8ea8f51ab27c149..1e02f6b357d65f90d23dfd1dbb6303d79c3124fa 100644 (file)
           </tbody>
         </table>
 
-      <!-- Tags -->
-      <ng-container *ngIf="(selection.tagset | keyvalue)?.length">
-        <legend i18n>Tags</legend>
-        <table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
-          <tbody>
-            <tr *ngFor="let tag of selection.tagset | keyvalue">
-              <td i18n
-                  class="bold w-25">{{tag.key}}</td>
-              <td class="w-75">{{ tag.value }}</td>
-            </tr>
-          </tbody>
-        </table>
-      </ng-container>
+        <!-- Tags -->
+        <ng-container *ngIf="(selection.tagset | keyvalue)?.length">
+          <legend i18n>Tags</legend>
+          <table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
+            <tbody>
+              <tr *ngFor="let tag of selection.tagset | keyvalue">
+                <td i18n
+                    class="bold w-25">{{tag.key}}</td>
+                <td class="w-75">{{ tag.value }}</td>
+              </tr>
+            </tbody>
+          </table>
+        </ng-container>
+
 
+        <!-- Bucket Rate Limit -->
+        <ng-container *ngIf="!!bucketRateLimit">
+          <cd-rgw-rate-limit-details [rateLimitConfig]="bucketRateLimit"
+                                     [type]="'bucket'"></cd-rgw-rate-limit-details>
+        </ng-container>
       </ng-template>
     </ng-container>
 
index be6aa09182ca62b224098a0612eff545c3b14dd1..efef91826d1161ae7e2338487d0a9431b6f63999 100644 (file)
@@ -41,4 +41,126 @@ describe('RgwBucketDetailsComponent', () => {
     component.ngOnChanges();
     expect(rgwBucketServiceGetSpy).toHaveBeenCalled();
   });
+  it('should retrieve bucket details and set selection when selection is provided', () => {
+    const bucket = { bid: 'bucket', acl: '<xml></xml>', owner: 'owner' };
+    rgwBucketServiceGetSpy.and.returnValue(of(bucket));
+    component.selection = { bid: 'bucket' };
+    component.ngOnChanges();
+    expect(rgwBucketServiceGetSpy).toHaveBeenCalledWith('bucket');
+    expect(component.selection).toEqual(jasmine.objectContaining(bucket));
+  });
+
+  it('should set default lifecycle when lifecycleFormat is json and lifecycle is not provided', () => {
+    const bucket = { bid: 'bucket', acl: '<xml></xml>', owner: 'owner' };
+    rgwBucketServiceGetSpy.and.returnValue(of(bucket));
+    component.selection = { bid: 'bucket' };
+    component.lifecycleFormat = 'json';
+    component.ngOnChanges();
+    expect(component.selection.lifecycle).toEqual({});
+  });
+
+  it('should parse ACL and set aclPermissions', () => {
+    const bucket = { bid: 'bucket', acl: '<xml></xml>', owner: 'owner' };
+    rgwBucketServiceGetSpy.and.returnValue(of(bucket));
+    spyOn(component, 'parseXmlAcl').and.returnValue({ Owner: ['READ'] });
+    component.selection = { bid: 'bucket' };
+    component.ngOnChanges();
+    expect(component.aclPermissions).toEqual({ Owner: ['READ'] });
+  });
+
+  it('should set replicationStatus when replication status is provided', () => {
+    const bucket = {
+      bid: 'bucket',
+      acl: '<xml></xml>',
+      owner: 'owner',
+      replication: { Rule: { Status: 'Enabled' } }
+    };
+    rgwBucketServiceGetSpy.and.returnValue(of(bucket));
+    component.selection = { bid: 'bucket' };
+    component.ngOnChanges();
+    expect(component.replicationStatus).toBe('Disabled');
+  });
+
+  it('should set bucketRateLimit when getBucketRateLimit is called', () => {
+    const rateLimit = { bucket_ratelimit: { max_size: 1000 } };
+    spyOn(rgwBucketService, 'getBucketRateLimit').and.returnValue(of(rateLimit));
+    component.selection = { bid: 'bucket' };
+    component.ngOnChanges();
+    expect(component.bucketRateLimit).toEqual(rateLimit.bucket_ratelimit);
+  });
+
+  it('should return default permissions when ACL is empty', () => {
+    const xml = `
+    <AccessControlPolicy>
+      <AccessControlList>
+        <Grant>
+        </Grant>
+      </AccessControlList>
+    </AccessControlPolicy>
+  `;
+    const result = component.parseXmlAcl(xml, 'owner');
+    expect(result).toEqual({
+      Owner: ['-'],
+      AllUsers: ['-'],
+      AuthenticatedUsers: ['-']
+    });
+  });
+
+  it('should return owner permissions when ACL contains owner ID', () => {
+    const xml = `
+    <AccessControlPolicy>
+      <AccessControlList>
+        <Grant>
+          <Grantee>
+            <ID>owner</ID>
+          </Grantee>
+          <Permission>FULL_CONTROL</Permission>
+        </Grant>
+      </AccessControlList>
+    </AccessControlPolicy>
+  `;
+    const result = component.parseXmlAcl(xml, 'owner');
+    expect(result.Owner).toEqual('FULL_CONTROL');
+  });
+
+  it('should return group permissions when ACL contains group URI', () => {
+    const xml = `
+    <AccessControlPolicy>
+      <AccessControlList>
+        <Grant>
+          <Grantee>
+            <URI>http://acs.amazonaws.com/groups/global/AllUsers</URI>
+          </Grantee>
+          <Permission>READ</Permission>
+        </Grant>
+      </AccessControlList>
+    </AccessControlPolicy>
+  `;
+    const result = component.parseXmlAcl(xml, 'owner');
+    expect(result.AllUsers).toEqual(['-']);
+  });
+
+  it('should handle multiple grants correctly', () => {
+    const xml = `
+    <AccessControlPolicy>
+      <AccessControlList>
+        <Grant>
+          <Grantee>
+            <URI>http://acs.amazonaws.com/groups/global/AllUsers</URI>
+          </Grantee>
+          <Permission>READ</Permission>
+        </Grant>
+        <Grant>
+          <Grantee>
+            <URI>http://acs.amazonaws.com/groups/global/AuthenticatedUsers</URI>
+          </Grantee>
+          <Permission>WRITE</Permission>
+        </Grant>
+      </AccessControlList>
+    </AccessControlPolicy>
+  `;
+    const result = component.parseXmlAcl(xml, 'owner');
+    expect(result.AllUsers).toEqual(['READ']);
+    expect(result.AuthenticatedUsers).toEqual(['WRITE']);
+  });
 });
index 79e25808b93ea2888e6fb3d814743264e3fce942..c970a4eee12e730fc451c52904661cdd3e5459de 100644 (file)
@@ -3,6 +3,7 @@ import { Component, Input, OnChanges } from '@angular/core';
 import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
 
 import * as xml2js from 'xml2js';
+import { RgwRateLimitConfig } from '../models/rgw-rate-limit';
 
 @Component({
   selector: 'cd-rgw-bucket-details',
@@ -21,6 +22,7 @@ export class RgwBucketDetailsComponent implements OnChanges {
   lifecycleFormat: 'json' | 'xml' = 'json';
   aclPermissions: Record<string, string[]> = {};
   replicationStatus = $localize`Disabled`;
+  bucketRateLimit: RgwRateLimitConfig;
 
   constructor(private rgwBucketService: RgwBucketService) {}
 
@@ -46,6 +48,11 @@ export class RgwBucketDetailsComponent implements OnChanges {
           );
         }
       });
+      this.rgwBucketService.getBucketRateLimit(this.selection.bid).subscribe((resp: any) => {
+        if (resp && resp.bucket_ratelimit !== undefined) {
+          this.bucketRateLimit = resp.bucket_ratelimit;
+        }
+      });
     }
   }
 
index 9c07182a0e597eb0e77a5e899442445dd6abf298..df6150d028a6be70144fea78c5f615845818dd32 100644 (file)
             </select>
             <span class="invalid-feedback"
                   *ngIf="bucketForm.showError('owner', frm, 'required')"
-                  i18n>This field is required.</span>
-            <cd-alert-panel
-              type="info"
-              *ngIf="bucketForm.get('owner').disabled"
-              spacingClass="me-1 mt-1"
-              i18n>
-                The bucket is owned by an account. UI does not support changing
-                the ownership of bucket owned by an account.
+                  i18n>This field is
+              required.</span>
+            <cd-alert-panel type="info"
+                            *ngIf="bucketForm.get('owner').disabled"
+                            spacingClass="me-1 mt-1"
+                            i18n>The bucket is owned by an account. UI does not support changing the ownership of bucket owned by an account.
             </cd-alert-panel>
           </div>
         </div>
                        name="versioning"
                        formControlName="versioning"
                        (change)="setMfaDeleteValidators()">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="versioning"
                        i18n>Enabled</label>
                 <cd-helper>
         <!-- Object Locking -->
         <fieldset *ngIf="!editing || (editing && bucketForm.getValue('lock_enabled'))">
           <legend class="cd-header"
-                  i18n>
-            Object Locking
+                  i18n>Object Locking
             <cd-help-text>
-                Store objects using a write-once-read-many (WORM) model to prevent objects from being deleted or overwritten for a fixed amount of time or indefinitely.
-                Object Locking works only in versioned buckets.
+              Store objects using a write-once-read-many (WORM) model to prevent objects from being deleted or
+              overwritten for a fixed amount of time or indefinitely.
+              Object Locking works only in versioned buckets.
             </cd-help-text>
           </legend>
           <!-- Object Locking enable -->
             <label class="cd-col-form-label pt-0"
                    for="lock_enabled"
                    i18n>
-                    Enable
+              Enable
             </label>
             <div class="cd-col-form-input">
               <input class="form-check-input"
                      id="lock_enabled"
                      formControlName="lock_enabled"
-                     type="checkbox"/>
+                     type="checkbox" />
               <cd-help-text>
                 <span i18n>Enables locking for the objects in the bucket. Locking can only be enabled while creating a bucket.</span>
               </cd-help-text>
                       name="lock_mode"
                       id="lock_mode">
                 <option i18n
-                        value="COMPLIANCE" >
+                        value="COMPLIANCE">
                   Compliance
                 </option>
                 <option i18n
               </select>
               <cd-help-text>
                 <span *ngIf="bucketForm.getValue('lock_mode') === 'COMPLIANCE'"
-                      i18n>
-                  In COMPLIANCE an object version cannot be overwritten or deleted for the duration of the period.
+                      i18n>In COMPLIANCE an object version cannot be overwritten or deleted for the duration of the period.
                 </span>
                 <span *ngIf="bucketForm.getValue('lock_mode') === 'GOVERNANCE'"
-                      i18n>
-                  In GOVERNANCE mode, users cannot overwrite or delete an object version or alter its lock settings unless they have special permissions.
+                      i18n>In GOVERNANCE mode, users cannot overwrite or delete an object version or alter its lock settings unless they have special permissions.
                 </span>
               </cd-help-text>
             </div>
                      formControlName="lock_retention_period_days"
                      min="1">
               <cd-help-text>
-                <span i18n>The number of days that you want to specify for the default retention period that will be applied to new objects placed in this bucket.</span>
+                <span i18n>The number of days that you want to specify for the default retention period that will be
+                  applied to new objects placed in this bucket.</span>
               </cd-help-text>
               <span class="invalid-feedback"
                     *ngIf="bucketForm.showError('lock_retention_period_days', frm, 'pattern')"
           <div class="form-group row">
             <div class="cd-col-form-label"></div>
             <div class="cd-col-form-input">
-              <cd-alert-panel
-                type="info"
-                *ngIf="bucketForm.getValue('lock_enabled')"
-                class="me-1"
-                i18n-title>
-                  Bucket Versioning can't be disabled when Object Locking is enabled.
+              <cd-alert-panel type="info"
+                              *ngIf="bucketForm.getValue('lock_enabled')"
+                              class="me-1"
+                              i18n-title>
+                Bucket Versioning can't be disabled when Object Locking is enabled.
               </cd-alert-panel>
-              <cd-alert-panel
-                type="warning"
-                *ngIf="bucketForm.getValue('lock_enabled')">
-                  Enabling Object Locking will allow the configuration of GOVERNANCE or COMPLIANCE modes, which will help ensure that an object version cannot be overwritten or deleted for the specified period.
+              <cd-alert-panel type="warning"
+                              *ngIf="bucketForm.getValue('lock_enabled')">
+                Enabling Object Locking will allow the configuration of GOVERNANCE or COMPLIANCE modes, which will help
+                ensure that an object version cannot be overwritten or deleted for the specified period.
               </cd-alert-panel>
             </div>
           </div>
         </fieldset>
 
-        <!-- Encryption -->
-        <fieldset>
-          <legend class="cd-header"
-                  i18n>Encryption</legend>
-          <div class="form-group row">
-            <label class="cd-col-form-label pt-0"
-                   for="encryption_enabled"
-                   i18n>
-                    Enable
-            </label>
-            <div class="cd-col-form-input">
-              <input class="form-check-input"
-                     id="encryption_enabled"
-                     name="encryption_enabled"
-                     formControlName="encryption_enabled"
-                     type="checkbox"
-                     [attr.disabled]="!kmsConfigured && !s3Configured ? true : null"/>
-              <cd-help-text aria-label="encryption helper">
-                <span i18n>Enables encryption for the objects in the bucket.
-                    To enable encryption on a bucket you need to set the configuration values for SSE-S3 or SSE-KMS.
-                    To set the configuration values <a href="#/rgw/configuration"
-                                                       aria-label="click here">Click here</a></span>
-              </cd-help-text>
-            </div>
-          </div>
-
-          <div *ngIf="bucketForm.getValue('encryption_enabled')">
-            <div class="form-group row">
-              <div class="cd-col-form-offset">
-                <div class="custom-control custom-radio custom-control-inline ps-5">
+          <!-- Encryption -->
+            <fieldset>
+              <legend class="cd-header"
+                      i18n>Encryption</legend>
+              <div class="form-group row">
+                <label class="cd-col-form-label pt-0"
+                       for="encryption_enabled"
+                       i18n>Enable
+                </label>
+                <div class="cd-col-form-input">
+                <input  class="form-check-input"
+                        id="encryption_enabled"
+                        name="encryption_enabled"
+                        formControlName="encryption_enabled"
+                        type="checkbox"
+                        [attr.disabled]="!kmsConfigured && !s3Configured ? true : null" />
+                  <cd-help-text aria-label="encryption helper">
+                  <span i18n>Enables encryption for the objects in the bucket.
+                        To enable encryption on a bucket you need to set the configuration values for SSE-S3 or SSE-KMS.
+                          To set the configuration values
+                  <a href="#/rgw/configuration"
+                     aria-label="click here">Click here</a></span>
+                  </cd-help-text>
+                </div>
+              </div>
+            <div *ngIf="bucketForm.getValue('encryption_enabled')">
+              <div class="form-group row">
+                <div class="cd-col-form-offset">
+                  <div class="custom-control custom-radio custom-control-inline ps-5">
                   <input class="form-check-input"
                          formControlName="encryption_type"
                          id="sse_S3_enabled"
                          [ngClass]="{'text-muted': !s3Configured}"
                          for="sse_S3_enabled"
                          i18n>SSE-S3</label>
+                  </div>
                 </div>
               </div>
-            </div>
 
             <div class="form-group row">
               <div class="cd-col-form-offset">
             <label class="cd-col-form-label pt-0"
                    for="replication"
                    i18n>
-                    Enable
+              Enable
             </label>
             <div class="cd-col-form-input"
                  *ngIf="{status: multisiteStatus$, isDefaultZg: isDefaultZoneGroup$ | async} as multisiteStatus; else loadingTpl">
           <div class="row">
             <div class="col-12">
               <strong *ngIf="tags.length > 19"
-                      class="text-warning"
-                      i18n>Maximum of 20 tags reached</strong>
+                       class="text-warning"
+                       i18n>Maximum of 20 tags reached</strong>
               <button type="button"
                       id="add-tag"
                       class="btn btn-light float-end my-3"
               <!-- Lifecycle -->
               <div *ngIf="editing"
                    class="form-group row">
-              <label i18n
-                     class="cd-col-form-label"
-                     for="id">Lifecycle
-                <cd-helper>JSON or XML formatted document</cd-helper>
-              </label>
+                <label i18n
+                       class="cd-col-form-label"
+                       for="id">Lifecycle
+                  <cd-helper>JSON or XML formatted document</cd-helper>
+                </label>
                 <div class="cd-col-form-input">
                   <textarea #lifecycleTextArea
                             class="form-control resize-vertical"
                           for="aclPermission"
                           i18n>Permissions
                       <cd-helper>Select the permision to give to the selected grantee.
-                          Regardless, the owner of the bucket will always have
-                            FULL CONTROL access</cd-helper>
-                      </span>
+                        Regardless, the owner of the bucket will always have
+                        FULL CONTROL access</cd-helper>
+                    </span>
                     <select id="aclPermission"
                             name="aclPermission"
                             class="form-input form-select"
                             formControlName="aclPermission">
                       <option *ngFor="let permission of aclPermissions"
                               [value]="permission"
-                              i18n>{{ permission }}</option>
+                              i18n>{{ permission }}
+                      </option>
                     </select>
                     <span class="invalid-feedback"
                           *ngIf="bucketForm.showError('aclPermission', frm, 'required')"
         </fieldset>
 
         <!--Advanced-->
-        <cd-form-advanced-fieldset *ngIf="!editing">
+        <cd-form-advanced-fieldset>
           <!-- Placement target -->
-          <div class="form-group row">
+          <div class="form-group row"
+               *ngIf="!editing">
             <label class="cd-col-form-label"
                    for="placement-target"
                    i18n>Placement target</label>
                         *ngIf="placementTargets !== null"
                         [ngValue]="null">-- Select a placement target --</option>
                 <option *ngFor="let placementTarget of placementTargets"
-                        [value]="placementTarget.name">{{ placementTarget.description }}</option>
+                        [value]="placementTarget.name">{{placementTarget.description }}</option>
               </select>
               <cd-help-text>
                 <span i18n>
               </cd-help-text>
             </div>
           </div>
+          <!-- Bucket Rate Limit -->
+
+          <cd-rgw-rate-limit [type]="'bucket'"
+                             [isEditing]="this.editing"
+                             [allowBid]="this.bucketForm.getValue('bid')"
+                             (rateLimitFormGroup)="rateLimitFormInit($event)"></cd-rgw-rate-limit>
         </cd-form-advanced-fieldset>
       </div>
 
     <cd-loading-panel i18n>Checking multi-site status...</cd-loading-panel>
   </div>
 </ng-template>
+
index 34619824f206839b307287d01b94439481b3552a..24090aba90597601d3704becdcdf796691fe1901 100644 (file)
@@ -18,6 +18,7 @@ import { configureTestBed, FormHelper } from '~/testing/unit-test-helper';
 import { RgwBucketMfaDelete } from '../models/rgw-bucket-mfa-delete';
 import { RgwBucketVersioning } from '../models/rgw-bucket-versioning';
 import { RgwBucketFormComponent } from './rgw-bucket-form.component';
+import { RgwRateLimitComponent } from '../rgw-rate-limit/rgw-rate-limit.component';
 
 describe('RgwBucketFormComponent', () => {
   let component: RgwBucketFormComponent;
@@ -29,7 +30,7 @@ describe('RgwBucketFormComponent', () => {
   let formHelper: FormHelper;
 
   configureTestBed({
-    declarations: [RgwBucketFormComponent],
+    declarations: [RgwBucketFormComponent, RgwRateLimitComponent],
     imports: [
       HttpClientTestingModule,
       ReactiveFormsModule,
@@ -153,16 +154,19 @@ describe('RgwBucketFormComponent', () => {
         'mfa-delete': mfaDeleteChecked
       });
       fixture.detectChanges();
-
-      const mfaTokenSerial = fixture.debugElement.nativeElement.querySelector('#mfa-token-serial');
-      const mfaTokenPin = fixture.debugElement.nativeElement.querySelector('#mfa-token-pin');
-      if (expectedVisibility) {
-        expect(mfaTokenSerial).toBeTruthy();
-        expect(mfaTokenPin).toBeTruthy();
-      } else {
-        expect(mfaTokenSerial).toBeFalsy();
-        expect(mfaTokenPin).toBeFalsy();
-      }
+      fixture.whenStable().then(() => {
+        const mfaTokenSerial = fixture.debugElement.nativeElement.querySelector(
+          '#mfa-token-serial'
+        );
+        const mfaTokenPin = fixture.debugElement.nativeElement.querySelector('#mfa-token-pin');
+        if (expectedVisibility) {
+          expect(mfaTokenSerial).toBeTruthy();
+          expect(mfaTokenPin).toBeTruthy();
+        } else {
+          expect(mfaTokenSerial).toBeFalsy();
+          expect(mfaTokenPin).toBeFalsy();
+        }
+      });
     };
 
     it('inputs should be visible when required', () => {
@@ -315,4 +319,18 @@ describe('RgwBucketFormComponent', () => {
       formHelper.expectValid('replication');
     });
   });
+
+  it('should call setTag', () => {
+    let tag = { key: 'test', value: 'test' };
+    // jest.spyOn(component.bucketForm,'markAsDirty')
+    component['setTag'](tag, 0);
+    expect(component.tags[0]).toEqual(tag);
+    expect(component.dirtyTags).toEqual(true);
+  });
+  it('should call deleteTag', () => {
+    component.tags = [{ key: 'test', value: 'test' }];
+    const updateValidationSpy = jest.spyOn(component.bucketForm, 'updateValueAndValidity');
+    component.deleteTag(0);
+    expect(updateValidationSpy).toHaveBeenCalled();
+  });
 });
index 53a1ac442c5300d4ac7c34a1b75a788a5c733644..53fdf5273b8beda9315b5a653d1046714df75662 100644 (file)
@@ -6,7 +6,7 @@ import {
   ViewChild,
   ElementRef
 } from '@angular/core';
-import { AbstractControl, Validators } from '@angular/forms';
+import { AbstractControl, FormGroup, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 
 import _ from 'lodash';
@@ -39,6 +39,8 @@ import { RgwMultisiteService } from '~/app/shared/api/rgw-multisite.service';
 import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
 import { map, switchMap } from 'rxjs/operators';
 import { TextAreaXmlFormatterService } from '~/app/shared/services/text-area-xml-formatter.service';
+import { RgwRateLimitComponent } from '../rgw-rate-limit/rgw-rate-limit.component';
+import { RgwRateLimitConfig } from '../models/rgw-rate-limit';
 
 @Component({
   selector: 'cd-rgw-bucket-form',
@@ -86,6 +88,8 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
     return this.bucketForm.getValue('mfa-delete');
   }
 
+  @ViewChild(RgwRateLimitComponent, { static: false }) rateLimitComponent!: RgwRateLimitComponent;
+
   constructor(
     private route: ActivatedRoute,
     private router: Router,
@@ -307,10 +311,12 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
   goToListView() {
     this.router.navigate(['/rgw/bucket']);
   }
-
+  rateLimitFormInit(rateLimitForm: FormGroup) {
+    this.bucketForm.addControl('rateLimit', rateLimitForm);
+  }
   submit() {
     // Exit immediately if the form isn't dirty.
-    if (this.bucketForm.pristine) {
+    if (this.bucketForm.pristine && this.rateLimitComponent.form.pristine) {
       this.goToListView();
       return;
     }
@@ -365,6 +371,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
               NotificationType.success,
               $localize`Updated Object Gateway bucket '${values.bid}'.`
             );
+            this.updateBucketRateLimit();
             this.goToListView();
           },
           () => {
@@ -398,6 +405,7 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
               $localize`Created Object Gateway bucket '${values.bid}'`
             );
             this.goToListView();
+            this.updateBucketRateLimit();
           },
           () => {
             // Reset the 'Submit' button.
@@ -407,6 +415,21 @@ export class RgwBucketFormComponent extends CdForm implements OnInit, AfterViewC
     }
   }
 
+  updateBucketRateLimit() {
+    // Check if bucket ratelimit has been modified.
+    const rateLimitConfig: RgwRateLimitConfig = this.rateLimitComponent.getRateLimitFormValue();
+    if (!!rateLimitConfig) {
+      this.rgwBucketService
+        .updateBucketRateLimit(this.bucketForm.getValue('bid'), rateLimitConfig)
+        .subscribe(
+          () => {},
+          (error: any) => {
+            this.notificationService.show(NotificationType.error, error);
+          }
+        );
+    }
+  }
+
   areMfaCredentialsRequired() {
     return (
       this.isMfaDeleteEnabled !== this.isMfaDeleteAlreadyEnabled ||
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.html
new file mode 100644 (file)
index 0000000..2b98a86
--- /dev/null
@@ -0,0 +1,51 @@
+<legend i18n>{{this.type === 'user'? 'User Rate Limit': 'Bucket Rate Limit'}}</legend>
+<table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
+  <tbody>
+    <tr>
+    <td i18n
+        class="bold w-25">Enabled</td>
+    <td class="w-75">{{ rateLimitConfig.enabled | booleanText }}</td>
+    </tr>
+    <tr>
+    <td i18n
+        class="bold">Maximum Read Ops</td>
+    <td *ngIf="!rateLimitConfig.enabled">-</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_read_ops <= 0"
+        i18n>Unlimited</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_read_ops > 0">
+        {{ rateLimitConfig.max_read_ops}}
+    </td>
+    </tr>
+    <tr>
+    <td i18n
+        class="bold">Maximum Write Ops</td>
+    <td *ngIf="!rateLimitConfig.enabled">-</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_write_ops <= 0"
+        i18n>Unlimited</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_write_ops > 0">
+        {{ rateLimitConfig.max_write_ops }}
+    </td>
+    </tr>
+    <tr>
+    <td i18n
+        class="bold">Maximum Read Bytes</td>
+    <td *ngIf="!rateLimitConfig.enabled">-</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_read_bytes <= 0"
+        i18n>Unlimited</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_read_bytes > 0">
+        {{ rateLimitConfig.max_read_bytes | dimlessBinaryPerMinute}}
+    </td>
+    </tr>
+    <tr>
+    <td i18n
+        class="bold">Maximum Write Bytes</td>
+    <td *ngIf="!rateLimitConfig.enabled">-</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_write_bytes <= 0"
+        i18n>Unlimited</td>
+    <td *ngIf="rateLimitConfig.enabled && rateLimitConfig.max_write_bytes > 0">
+        {{ rateLimitConfig.max_write_bytes | dimlessBinaryPerMinute}}
+    </td>
+    </tr>
+  </tbody>
+</table>
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.spec.ts
new file mode 100644 (file)
index 0000000..cc7256d
--- /dev/null
@@ -0,0 +1,32 @@
+import { SharedModule } from '~/app/shared/shared.module';
+import { RgwRateLimitDetailsComponent } from './rgw-rate-limit-details.component';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { configureTestBed } from '~/testing/unit-test-helper';
+
+describe('RgwRateLimitDetailsComponent', () => {
+  let component: RgwRateLimitDetailsComponent;
+  let fixture: ComponentFixture<RgwRateLimitDetailsComponent>;
+  configureTestBed({
+    declarations: [RgwRateLimitDetailsComponent],
+    imports: [HttpClientTestingModule, SharedModule]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwRateLimitDetailsComponent);
+    component = fixture.componentInstance;
+    component.type = 'user';
+    component.rateLimitConfig = {
+      enabled: true,
+      max_read_bytes: 987648,
+      max_read_ops: 0,
+      max_write_bytes: 0,
+      max_write_ops: 9876543212
+    };
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit-details/rgw-rate-limit-details.component.ts
new file mode 100644 (file)
index 0000000..d57f3fb
--- /dev/null
@@ -0,0 +1,12 @@
+import { Component, Input } from '@angular/core';
+import { RgwRateLimitConfig } from '../models/rgw-rate-limit';
+
+@Component({
+  selector: 'cd-rgw-rate-limit-details',
+  templateUrl: './rgw-rate-limit-details.component.html',
+  styleUrls: ['./rgw-rate-limit-details.component.scss']
+})
+export class RgwRateLimitDetailsComponent {
+  @Input() rateLimitConfig: RgwRateLimitConfig;
+  @Input() type: string;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.html
new file mode 100644 (file)
index 0000000..3a99a1b
--- /dev/null
@@ -0,0 +1,278 @@
+<fieldset *ngIf="type">
+  <legend i18n>
+    Rate Limit
+    <cd-help-text *ngIf="type === 'user'">
+      The User Rate Limit controls the max read/write operations and data per minute for each user.
+    </cd-help-text>
+    <cd-help-text *ngIf="type === 'bucket'">
+      The Bucket Rate Limit controls the max read/write operations and data per minute for each
+      bucket.
+    </cd-help-text>
+  </legend>
+  <form name="form"
+        #frm="ngForm"
+        [formGroup]="form"
+        novalidate>
+    <div class="row"
+         *ngIf="!!globalRateLimit && globalRateLimit.enabled">
+      <div class="col-3"></div>
+      <div class="col-9">
+        <div>
+          <cd-alert-panel type="info"
+                          class="me-1"
+                          id="global-ratelimit-info"
+                          i18n>
+            <div>
+              <span class="bold">Global Rate Limit</span> <br />Max. read bytes :
+              {{ globalRateLimit.max_read_bytes | dimlessBinaryPerMinute }} <br />Max. read ops :
+              {{ globalRateLimit.max_read_ops }} <br />Max. write bytes :
+              {{ globalRateLimit.max_write_bytes | dimlessBinaryPerMinute }} <br />Max. write ops :
+              {{ globalRateLimit.max_write_ops }}
+            </div>
+          </cd-alert-panel>
+        </div>
+      </div>
+    </div>
+    <!-- Enabled -->
+    <div class="form-group row">
+      <div class="cd-col-form-offset">
+        <div class="custom-control custom-checkbox">
+          <input
+            class="custom-control-input"
+            id="rate_limit_enabled"
+            type="checkbox"
+            formControlName="rate_limit_enabled"
+          />
+          <label class="custom-control-label"
+                 for="rate_limit_enabled"
+                 i18n>Enabled</label>
+          <cd-help-text i18n>Toggle to enable or disable the rate limit settings.</cd-help-text>
+        </div>
+      </div>
+    </div>
+    <!-- Unlimited size -->
+    <div class="form-group row"
+         *ngIf="form.controls.rate_limit_enabled.value">
+      <div class="cd-col-form-offset">
+        <div class="custom-control custom-checkbox">
+          <input
+            class="custom-control-input"
+            id="rate_limit_max_readOps_unlimited"
+            type="checkbox"
+            formControlName="rate_limit_max_readOps_unlimited"
+          />
+          <label class="custom-control-label"
+                 for="rate_limit_max_readOps_unlimited"
+                 i18n>Unlimited read ops
+          </label>
+          <cd-help-text i18n>Select this box to allow unlimited read operations.</cd-help-text>
+        </div>
+      </div>
+    </div>
+
+    <!-- Maximum size -->
+    <div
+      class="form-group row"
+      *ngIf="
+        form.controls.rate_limit_enabled.value && !form.getValue('rate_limit_max_readOps_unlimited')
+      "
+    >
+      <label class="cd-col-form-label required"
+             for="rate_limit_max_readOps"
+             i18n>Max. read ops</label>
+      <div class="cd-col-form-input">
+        <input
+          id="rate_limit_max_readOps"
+          class="form-control"
+          type="number"
+          formControlName="rate_limit_max_readOps"
+        />
+        <cd-help-text>Limits the number of read operations per minute for a user.</cd-help-text>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_readOps', frm, 'required')"
+          i18n
+          >This field is required.</span
+        >
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_readOps', frm, 'rateOpsMaxSize')"
+          i18n
+          >The value is not valid.</span
+        >
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_readOps', frm, 'min')"
+          i18n
+          >Enter a positive number.</span
+        >
+      </div>
+    </div>
+    <!-- Unlimited Write Ops -->
+    <div class="form-group row"
+         *ngIf="form.controls.rate_limit_enabled.value">
+      <div class="cd-col-form-offset">
+        <div class="custom-control custom-checkbox">
+          <input
+            class="custom-control-input"
+            id="rate_limit_max_writeOps_unlimited"
+            type="checkbox"
+            formControlName="rate_limit_max_writeOps_unlimited"
+          />
+          <label class="custom-control-label"
+                 for="rate_limit_max_writeOps_unlimited"
+                 i18n>Unlimited write ops
+          </label>
+          <cd-help-text i18n>Select this box to allow unlimited write operations.</cd-help-text>
+        </div>
+      </div>
+    </div>
+    <!-- Maximum Write Ops -->
+    <div
+      class="form-group row"
+      *ngIf="
+        form.controls.rate_limit_enabled.value &&
+        !form.getValue('rate_limit_max_writeOps_unlimited')
+      "
+    >
+      <label class="cd-col-form-label required"
+             for="rate_limit_max_writeOps"
+             i18n>Max. write ops</label>
+      <div class="cd-col-form-input">
+        <input
+          id="rate_limit_max_writeOps"
+          class="form-control"
+          type="number"
+          formControlName="rate_limit_max_writeOps"
+        />
+        <cd-help-text>Limits the number of write operations per minute for a user.</cd-help-text>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_writeOps', frm, 'required')"
+          i18n
+          >This field is required.</span
+        >
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_writeOps', frm, 'rateOpsMaxSize')"
+          i18n
+          >The value is not valid.</span
+        >
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_writeOps', frm, 'min')"
+          i18n
+          >Enter a positive number.</span
+        >
+      </div>
+    </div>
+    <!-- Unlimited Read Bytes -->
+    <div class="form-group row"
+         *ngIf="form.controls.rate_limit_enabled.value">
+      <div class="cd-col-form-offset">
+        <div class="custom-control custom-checkbox">
+          <input
+            class="custom-control-input"
+            id="rate_limit_max_readBytes_unlimited"
+            type="checkbox"
+            formControlName="rate_limit_max_readBytes_unlimited"
+          />
+          <label class="custom-control-label"
+                 for="rate_limit_max_readBytes_unlimited"
+                 i18n>Unlimited read bytes
+          </label>
+          <cd-help-text i18n>Select this box to allow unlimited read bytes.</cd-help-text>
+        </div>
+      </div>
+    </div>
+    <!-- Maximum Read Bytes -->
+    <div
+      class="form-group row"
+      *ngIf="
+        form.controls.rate_limit_enabled.value &&
+        !form.getValue('rate_limit_max_readBytes_unlimited')
+      "
+    >
+      <label class="cd-col-form-label required"
+             for="rate_limit_max_readBytes"
+             i18n>Max. read bytes</label
+      >
+      <div class="cd-col-form-input">
+        <input
+          id="rate_limit_max_readBytes"
+          class="form-control"
+          type="text"
+          defaultUnit="b"
+          formControlName="rate_limit_max_readBytes"
+          cdDimlessBinaryPerMinute
+        />
+        <cd-help-text>Limits the number of read bytes per minute for a user.</cd-help-text>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_readBytes', frm, 'required')"
+          i18n>This field is required.</span>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_readBytes', frm, 'rateByteMaxSize')"
+          i18n>The value is not valid.</span>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_readBytes', frm, 'min')"
+          i18n>Enter a positive number.</span>
+      </div>
+    </div>
+    <!-- Unlimited Write Bytes -->
+    <div class="form-group row"
+         *ngIf="form.controls.rate_limit_enabled.value">
+      <div class="cd-col-form-offset">
+        <div class="custom-control custom-checkbox">
+          <input
+            class="custom-control-input"
+            id="rate_limit_max_writeBytes_unlimited"
+            type="checkbox"
+            formControlName="rate_limit_max_writeBytes_unlimited"
+          />
+          <label class="custom-control-label"
+                 for="rate_limit_max_writeBytes_unlimited"
+                 i18n>Unlimited write bytes
+          </label>
+          <cd-help-text i18n>Select this box to allow unlimited write bytes.</cd-help-text>
+        </div>
+      </div>
+    </div>
+    <!-- Maximum Write Bytes -->
+    <div
+      class="form-group row"
+      *ngIf="
+        form.controls.rate_limit_enabled.value &&
+        !form.getValue('rate_limit_max_writeBytes_unlimited')
+      ">
+      <label class="cd-col-form-label required"
+             for="rate_limit_max_writeBytes"
+             i18n>Max. write bytes</label>
+      <div class="cd-col-form-input">
+        <input
+          id="rate_limit_max_writeBytes"
+          class="form-control"
+          type="text"
+          defaultUnit="b"
+          formControlName="rate_limit_max_writeBytes"
+          cdDimlessBinaryPerMinute
+        />
+        <cd-help-text>Limits the number of write bytes per minute for a user.</cd-help-text>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_writeBytes', frm, 'required')"
+          i18n>This field is required.</span>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_writeBytes', frm, 'rateByteMaxSize')"
+          i18n>The value is not valid.</span>
+        <span
+          class="invalid-feedback"
+          *ngIf="form.showError('rate_limit_max_writeBytes', frm, 'min')"
+          i18n>Enter a positive number.</span>
+      </div>
+    </div>
+  </form>
+</fieldset>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.spec.ts
new file mode 100644 (file)
index 0000000..2870f2b
--- /dev/null
@@ -0,0 +1,150 @@
+import { configureTestBed } from '~/testing/unit-test-helper';
+import { RgwRateLimitComponent } from './rgw-rate-limit.component';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { AbstractControl, ReactiveFormsModule, ValidationErrors } from '@angular/forms';
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { CheckboxModule, InputModule, NotificationService } from 'carbon-components-angular';
+import { ToastrModule } from 'ngx-toastr';
+import { RouterTestingModule } from '@angular/router/testing';
+import { SharedModule } from '~/app/shared/shared.module';
+
+describe('RgwRateLimitComponent', () => {
+  let component: RgwRateLimitComponent;
+  let fixture: ComponentFixture<RgwRateLimitComponent>;
+  configureTestBed({
+    imports: [
+      ReactiveFormsModule,
+      ToastrModule.forRoot(),
+      InputModule,
+      CheckboxModule,
+      HttpClientTestingModule,
+      ReactiveFormsModule,
+      RouterTestingModule,
+      SharedModule,
+      ToastrModule.forRoot(),
+      InputModule
+    ],
+    declarations: [RgwRateLimitComponent],
+    providers: [NotificationService]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(RgwRateLimitComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  it('should call populateFormValues', () => {
+    let data: any = {
+      enabled: true,
+      max_read_ops: 100,
+      max_write_ops: 200,
+      max_read_bytes: 10485760,
+      max_write_bytes: 10485760
+    };
+    const ratelimitPropSpy = spyOn(component as any, '_setRateLimitProperty');
+    component['populateFormValues'](data);
+    expect(ratelimitPropSpy).toHaveBeenCalled();
+  });
+
+  describe('test case for _getRateLimitArgs', () => {
+    it('should return expected result when all rate limits are specified', () => {
+      // Using patchValue to set form values
+      component.form.patchValue({
+        rate_limit_enabled: true,
+        rate_limit_max_readOps: 100,
+        rate_limit_max_writeOps: 200,
+        rate_limit_max_readBytes: '10Mb',
+        rate_limit_max_writeBytes: '10Mb',
+        rate_limit_max_readOps_unlimited: false,
+        rate_limit_max_writeOps_unlimited: false,
+        rate_limit_max_readBytes_unlimited: false,
+        rate_limit_max_writeBytes_unlimited: false
+      });
+      jest.spyOn(FormatterService.prototype, 'toBytes').mockReturnValue(10485760); // 10MB in bytes
+
+      const result = component['_getRateLimitArgs']();
+
+      expect(result).toEqual({
+        enabled: true,
+        max_read_ops: 100,
+        max_write_ops: 200,
+        max_read_bytes: 10485760, // Converted by FormatterService.toBytes
+        max_write_bytes: 10485760 // Converted by FormatterService.toBytes
+      });
+    });
+    it('should return default values for unlimited options', () => {
+      // Using patchValue to set unlimited flags
+      component.form.patchValue({
+        rate_limit_enabled: true,
+        rate_limit_max_readOps: 100,
+        rate_limit_max_writeOps: 200,
+        rate_limit_max_readBytes: '10MB',
+        rate_limit_max_writeBytes: '20MB',
+        rate_limit_max_readOps_unlimited: true, // Unlimited
+        rate_limit_max_writeOps_unlimited: true, // Unlimited
+        rate_limit_max_readBytes_unlimited: true, // Unlimited
+        rate_limit_max_writeBytes_unlimited: true // Unlimited
+      });
+
+      // Set return values for the spied methods (not used for unlimited cases)
+      jest.spyOn(FormatterService.prototype, 'toBytes').mockReturnValue(10485760); // 10MB in bytes
+
+      const result = component['_getRateLimitArgs']();
+
+      expect(result).toEqual({
+        enabled: true,
+        max_read_ops: 0, // Default value when unlimited
+        max_write_ops: 0, // Default value when unlimited
+        max_read_bytes: 0, // Default value when unlimited
+        max_write_bytes: 0 // Default value when unlimited
+      });
+    });
+  });
+
+  it('should call _setRateLimitProperty when value is equal to 0 ', () => {
+    const mockrateLimitKey = 'rate_limit_max_readBytes';
+    const mockunlimitedKey = 'rate_limit_max_readBytes_unlimited';
+    const mockproperty = 0;
+
+    component['_setRateLimitProperty'](mockrateLimitKey, mockunlimitedKey, mockproperty);
+    expect(component.form.getValue('rate_limit_max_readBytes_unlimited')).toEqual(true);
+    expect(component.form.getValue('rate_limit_max_readBytes')).toEqual('');
+  });
+  it('should call rateLimitIopmMaxSizeValidator and return result', () => {
+    const mockResult: ValidationErrors = { someError: true };
+    FormatterService.prototype.iopmMaxSizeValidator = jest.fn().mockReturnValue(mockResult);
+    const control: AbstractControl = { value: 'testValue' } as AbstractControl;
+    const result = component.rateLimitIopmMaxSizeValidator(control);
+    expect(FormatterService.prototype.iopmMaxSizeValidator).toHaveBeenCalledWith(control);
+    expect(result).toEqual(mockResult);
+  });
+  it('should call rateLimitIopmMaxSizeValidator and return null', () => {
+    FormatterService.prototype.iopmMaxSizeValidator = jest.fn().mockReturnValue(null);
+    const control: AbstractControl = { value: 'testValue' } as AbstractControl;
+    const result = component.rateLimitIopmMaxSizeValidator(control);
+    expect(FormatterService.prototype.iopmMaxSizeValidator).toHaveBeenCalledWith(control);
+    expect(result).toBeNull();
+  });
+  it('should call the rateLimitBytesMaxSizeValidator and FormatterService method with the correct parameters', () => {
+    const control: AbstractControl = {
+      value: '1000 K'
+    } as AbstractControl;
+
+    const serviceSpy = jest
+      .spyOn(FormatterService.prototype, 'performValidation')
+      .mockReturnValue(null);
+    const result = component.rateLimitBytesMaxSizeValidator(control);
+    expect(serviceSpy).toHaveBeenCalledWith(
+      control,
+      '^(\\d+(\\.\\d+)?)\\s*(B/m|K(B|iB/m)?|M(B|iB/m)?|G(B|iB/m)?|T(B|iB/m)?|P(B|iB/m)?)?$',
+      { rateByteMaxSize: true }
+    );
+    expect(result).toBeNull();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/rgw/rgw-rate-limit/rgw-rate-limit.component.ts
new file mode 100644 (file)
index 0000000..2cc291e
--- /dev/null
@@ -0,0 +1,269 @@
+import { AfterViewInit, Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
+import { GlobalRateLimitConfig, RgwRateLimitConfig } from '../models/rgw-rate-limit';
+import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
+import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
+import { AbstractControl, FormGroup, ValidationErrors, Validators } from '@angular/forms';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { RgwUserService } from '~/app/shared/api/rgw-user.service';
+import { RgwBucketService } from '~/app/shared/api/rgw-bucket.service';
+import _ from 'lodash';
+import { NotificationService } from '~/app/shared/services/notification.service';
+import { NotificationType } from '~/app/shared/enum/notification-type.enum';
+
+@Component({
+  selector: 'cd-rgw-rate-limit',
+  templateUrl: './rgw-rate-limit.component.html',
+  styleUrls: ['./rgw-rate-limit.component.scss']
+})
+export class RgwRateLimitComponent implements OnInit, AfterViewInit {
+  globalRateLimit: GlobalRateLimitConfig['user_ratelimit' | 'bucket_ratelimit'];
+  form: CdFormGroup;
+  @Input() type: string;
+
+  @Output() formValue = new EventEmitter();
+  @Output() rateLimitFormGroup = new EventEmitter<FormGroup>();
+
+  @Input()
+  isEditing: boolean;
+
+  @Input() id: string;
+
+  bid: string;
+  @Input() set allowBid(value: string) {
+    this.bid = value;
+    if (this.isEditing && !!this.bid && this.type == 'bucket') {
+      this.getRateLimitFormValues();
+    }
+  }
+  constructor(
+    private formBuilder: CdFormBuilder,
+    private rgwUserService: RgwUserService,
+    private rgwBucketService: RgwBucketService,
+    private notificationService: NotificationService
+  ) {}
+
+  ngOnInit(): void {
+    // get the global rate Limit
+    if (this.type === 'user') {
+      this.rgwUserService.getGlobalUserRateLimit().subscribe(
+        (data: GlobalRateLimitConfig) => {
+          if (data && data.user_ratelimit !== undefined) {
+            this.globalRateLimit = data.user_ratelimit;
+          }
+        },
+        (error: any) => {
+          this.notificationService.show(NotificationType.error, error);
+        }
+      );
+      this.isEditing ? this.getRateLimitFormValues() : '';
+    } else {
+      this.rgwBucketService.getGlobalBucketRateLimit().subscribe(
+        (data: GlobalRateLimitConfig) => {
+          if (data && data.bucket_ratelimit !== undefined) {
+            this.globalRateLimit = data.bucket_ratelimit;
+          }
+        },
+        (error: any) => {
+          this.notificationService.show(NotificationType.error, error);
+        }
+      );
+    }
+    // rate limit form
+    this.form = this.formBuilder.group({
+      rate_limit_enabled: [false],
+      rate_limit_max_readOps_unlimited: [true],
+      rate_limit_max_readOps: [
+        null,
+        [
+          CdValidators.composeIf(
+            {
+              rate_limit_enabled: true,
+              rate_limit_max_readOps_unlimited: false
+            },
+            [Validators.required, this.rateLimitIopmMaxSizeValidator]
+          )
+        ]
+      ],
+      rate_limit_max_writeOps_unlimited: [true],
+      rate_limit_max_writeOps: [
+        null,
+        [
+          CdValidators.composeIf(
+            {
+              rate_limit_enabled: true,
+              rate_limit_max_writeOps_unlimited: false
+            },
+            [Validators.required, this.rateLimitIopmMaxSizeValidator]
+          )
+        ]
+      ],
+      rate_limit_max_readBytes_unlimited: [true],
+      rate_limit_max_readBytes: [
+        null,
+        [
+          CdValidators.composeIf(
+            {
+              rate_limit_enabled: true,
+              rate_limit_max_readBytes_unlimited: false
+            },
+            [Validators.required, this.rateLimitBytesMaxSizeValidator]
+          )
+        ]
+      ],
+      rate_limit_max_writeBytes_unlimited: [true],
+      rate_limit_max_writeBytes: [
+        null,
+        [
+          CdValidators.composeIf(
+            {
+              rate_limit_enabled: true,
+              rate_limit_max_writeBytes_unlimited: false
+            },
+            [Validators.required, this.rateLimitBytesMaxSizeValidator]
+          )
+        ]
+      ]
+    });
+  }
+  /**
+   * Helper function to populate Form Values
+   * when edit rate limit edit is called.
+   */
+  private populateFormValues(data: RgwRateLimitConfig) {
+    this.form.get('rate_limit_enabled').setValue(data.enabled);
+    this._setRateLimitProperty(
+      'rate_limit_max_readBytes',
+      'rate_limit_max_readBytes_unlimited',
+      data.max_read_bytes
+    );
+    this._setRateLimitProperty(
+      'rate_limit_max_writeBytes',
+      'rate_limit_max_writeBytes_unlimited',
+      data.max_write_bytes
+    );
+    this._setRateLimitProperty(
+      'rate_limit_max_readOps',
+      'rate_limit_max_readOps_unlimited',
+      data.max_read_ops
+    );
+    this._setRateLimitProperty(
+      'rate_limit_max_writeOps',
+      'rate_limit_max_writeOps_unlimited',
+      data.max_write_ops
+    );
+  }
+  /**
+   * Helper function to call api and get Rate Limit Values
+   * on load for user and bucket
+   */
+  private getRateLimitFormValues() {
+    if (this.type === 'user') {
+      this.rgwUserService.getUserRateLimit(this.id).subscribe(
+        (resp: GlobalRateLimitConfig) => {
+          this.populateFormValues(resp.user_ratelimit);
+        },
+        (error: any) => {
+          this.notificationService.show(NotificationType.error, error);
+        }
+      );
+    } else {
+      this.rgwBucketService.getBucketRateLimit(this.bid).subscribe(
+        (resp: GlobalRateLimitConfig) => {
+          this.populateFormValues(resp.bucket_ratelimit);
+        },
+        (error: any) => {
+          this.notificationService.show(NotificationType.error, error);
+        }
+      );
+    }
+  }
+  /**
+   * Validate the rate limit bytes maximum size, e.g. 30, 1K, 30 PiB/m or 1.9 MiB/m.
+   */
+  rateLimitBytesMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
+    return new FormatterService().performValidation(
+      control,
+      '^(\\d+(\\.\\d+)?)\\s*(B/m|K(B|iB/m)?|M(B|iB/m)?|G(B|iB/m)?|T(B|iB/m)?|P(B|iB/m)?)?$',
+      { rateByteMaxSize: true }
+    );
+  }
+  /**
+   * Validate the rate limit operations maximum size
+   */
+  rateLimitIopmMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
+    return new FormatterService().iopmMaxSizeValidator(control);
+  }
+  getRateLimitFormValue() {
+    if (this._isRateLimitFormDirty()) return this._getRateLimitArgs();
+    return null;
+  }
+  ngAfterViewInit() {
+    this.rateLimitFormGroup.emit(this.form);
+  }
+  /**
+   * Check if the user rate limit has been modified.
+   * @return {Boolean} Returns TRUE if the user rate limit has been modified.
+   */
+  private _isRateLimitFormDirty(): boolean {
+    return [
+      'rate_limit_enabled',
+      'rate_limit_max_readOps_unlimited',
+      'rate_limit_max_readOps',
+      'rate_limit_max_writeOps_unlimited',
+      'rate_limit_max_writeOps',
+      'rate_limit_max_readBytes_unlimited',
+      'rate_limit_max_readBytes',
+      'rate_limit_max_writeBytes_unlimited',
+      'rate_limit_max_writeBytes'
+    ].some((path) => {
+      return this.form.get(path).dirty;
+    });
+  }
+  /**
+   * Helper function to get the arguments for the API request when the user
+   * rate limit configuration has been modified.
+   */
+  private _getRateLimitArgs(): RgwRateLimitConfig {
+    const result: RgwRateLimitConfig = {
+      enabled: this.form.getValue('rate_limit_enabled'),
+      max_read_ops: 0,
+      max_write_ops: 0,
+      max_read_bytes: 0,
+      max_write_bytes: 0
+    };
+    if (!this.form.getValue('rate_limit_max_readOps_unlimited')) {
+      result['max_read_ops'] = this.form.getValue('rate_limit_max_readOps');
+    }
+    if (!this.form.getValue('rate_limit_max_writeOps_unlimited')) {
+      result['max_write_ops'] = this.form.getValue('rate_limit_max_writeOps');
+    }
+    if (!this.form.getValue('rate_limit_max_readBytes_unlimited')) {
+      // Convert the given value to bytes.
+      result['max_read_bytes'] = new FormatterService().toBytes(
+        this.form.getValue('rate_limit_max_readBytes')
+      );
+    }
+    if (!this.form.getValue('rate_limit_max_writeBytes_unlimited')) {
+      result['max_write_bytes'] = new FormatterService().toBytes(
+        this.form.getValue('rate_limit_max_writeBytes')
+      );
+    }
+    return result;
+  }
+
+  /**
+   * Helper function to map the values for the Rate Limit when the user
+   * rate limit gets loaded for first time or edited.
+   */
+
+  private _setRateLimitProperty(rateLimitKey: string, unlimitedKey: string, property: number) {
+    if (property === 0) {
+      this.form.get(unlimitedKey).setValue(true);
+      this.form.get(rateLimitKey).setValue('');
+    } else {
+      this.form.get(unlimitedKey).setValue(false);
+      this.form.get(rateLimitKey).setValue(property);
+    }
+  }
+}
index a467f62b0f3f2ab235255b3bdc7a17b06c06b6ba..055dd54f13f7f16783ce3fb77ff34b1116d4ce7d 100644 (file)
@@ -2,21 +2,27 @@
   <div *ngIf="user">
     <div *ngIf="keys.length">
       <legend i18n>Keys</legend>
-      <cd-table [data]="keys"
-                [columns]="keysColumns"
-                columnMode="flex"
-                selectionType="single"
-                forceIdentifier="true"
-                (updateSelection)="updateKeysSelection($event)">
-        <cd-table-actions class="table-actions"
-                          [permission]="{read: true}"
-                          [selection]="selection"
-                          [tableActions]="tableAction"></cd-table-actions>
+      <cd-table
+        [data]="keys"
+        [columns]="keysColumns"
+        columnMode="flex"
+        selectionType="single"
+        forceIdentifier="true"
+        (updateSelection)="updateKeysSelection($event)"
+      >
+        <cd-table-actions
+          class="table-actions"
+          [permission]="{ read: true }"
+          [selection]="selection"
+          [tableActions]="tableAction"
+        ></cd-table-actions>
       </cd-table>
     </div>
 
     <legend i18n>Details</legend>
-    <table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
+    <table
+      class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
+    >
       <tbody>
         <tr>
           <td i18n
@@ -56,7 +62,7 @@
         <tr>
           <td i18n
               class="bold">Maximum buckets</td>
-          <td>{{ user.max_buckets | map:maxBucketsMap }}</td>
+          <td>{{ user.max_buckets | map: maxBucketsMap }}</td>
         </tr>
         <tr *ngIf="user.subusers && user.subusers.length">
           <td i18n
           <td i18n
               class="bold">Capabilities</td>
           <td>
-            <div *ngFor="let cap of user.caps">
-              {{ cap.type }} ({{ cap.perm }})
-            </div>
+            <div *ngFor="let cap of user.caps">{{ cap.type }} ({{ cap.perm }})</div>
           </td>
         </tr>
         <tr *ngIf="user.mfa_ids?.length">
           <td i18n
               class="bold">MFAs(Id)</td>
-          <td>{{ user.mfa_ids | join}}</td>
+          <td>{{ user.mfa_ids | join }}</td>
         </tr>
       </tbody>
     </table>
@@ -87,7 +91,9 @@
     <!-- User quota -->
     <div *ngIf="user.user_quota">
       <legend i18n>User quota</legend>
-      <table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
+      <table
+        class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
+      >
         <tbody>
           <tr>
             <td i18n
                 class="bold">Maximum size</td>
             <td *ngIf="!user.user_quota.enabled">-</td>
             <td *ngIf="user.user_quota.enabled && user.user_quota.max_size <= -1"
-                i18n>Unlimited</td>
+                i18n>
+              Unlimited
+            </td>
             <td *ngIf="user.user_quota.enabled && user.user_quota.max_size > -1">
               {{ user.user_quota.max_size | dimlessBinary }}
             </td>
                 class="bold">Maximum objects</td>
             <td *ngIf="!user.user_quota.enabled">-</td>
             <td *ngIf="user.user_quota.enabled && user.user_quota.max_objects <= -1"
-                i18n>Unlimited</td>
+                i18n>
+              Unlimited
+            </td>
             <td *ngIf="user.user_quota.enabled && user.user_quota.max_objects > -1">
               {{ user.user_quota.max_objects }}
             </td>
     <!-- Bucket quota -->
     <div *ngIf="user.bucket_quota">
       <legend i18n>Bucket quota</legend>
-      <table class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md">
+      <table
+        class="cds--data-table--sort cds--data-table--no-border cds--data-table cds--data-table--md"
+      >
         <tbody>
           <tr>
             <td i18n
                 class="bold">Maximum size</td>
             <td *ngIf="!user.bucket_quota.enabled">-</td>
             <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_size <= -1"
-                i18n>Unlimited</td>
+                i18n>
+              Unlimited
+            </td>
             <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_size > -1">
               {{ user.bucket_quota.max_size | dimlessBinary }}
             </td>
                 class="bold">Maximum objects</td>
             <td *ngIf="!user.bucket_quota.enabled">-</td>
             <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_objects <= -1"
-                i18n>Unlimited</td>
+                i18n>
+              Unlimited
+            </td>
             <td *ngIf="user.bucket_quota.enabled && user.bucket_quota.max_objects > -1">
               {{ user.bucket_quota.max_objects }}
             </td>
         </tbody>
       </table>
     </div>
+    <!-- User Rate Limit -->
+    <div *ngIf="user.user_ratelimit">
+      <cd-rgw-rate-limit-details
+        [rateLimitConfig]="user.user_ratelimit"
+        [type]="'user'"
+      ></cd-rgw-rate-limit-details>
+    </div>
   </div>
 </ng-container>
index f2fc7f4988028b36485cef9c9b1f35823ad406bb..01b28dc3481d5d91f8655629d859ffe6c6d96cc3 100644 (file)
@@ -7,14 +7,16 @@ import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap';
 import { SharedModule } from '~/app/shared/shared.module';
 import { configureTestBed } from '~/testing/unit-test-helper';
 import { RgwUserDetailsComponent } from './rgw-user-details.component';
+import { ModalService } from 'carbon-components-angular';
 
 describe('RgwUserDetailsComponent', () => {
   let component: RgwUserDetailsComponent;
   let fixture: ComponentFixture<RgwUserDetailsComponent>;
-
+  let modalRef: any;
   configureTestBed({
     declarations: [RgwUserDetailsComponent],
-    imports: [BrowserAnimationsModule, HttpClientTestingModule, SharedModule, NgbNavModule]
+    imports: [BrowserAnimationsModule, HttpClientTestingModule, SharedModule, NgbNavModule],
+    provider: [ModalService]
   });
 
   beforeEach(() => {
@@ -66,4 +68,48 @@ describe('RgwUserDetailsComponent', () => {
     expect(detailsTab[14].textContent).toEqual('MFAs(Id)');
     expect(detailsTab[15].textContent).toEqual('testMFA1, testMFA2');
   });
+  it('should test updateKeysSelection', () => {
+    component.selection = {
+      hasMultiSelection: false,
+      hasSelection: false,
+      hasSingleSelection: false,
+      _selected: []
+    };
+    component.updateKeysSelection(component.selection);
+    expect(component.keysSelection).toEqual(component.selection);
+  });
+  it('should call showKeyModal when key selection is of type S3', () => {
+    component.keysSelection.first = () => {
+      return { type: 'S3', ref: { user: '', access_key: '', secret_key: '' } };
+    };
+    const modalShowSpy = spyOn(component['modalService'], 'show').and.callFake(() => {
+      modalRef = {
+        componentInstance: {
+          setValues: jest.fn(),
+          setViewing: jest.fn()
+        }
+      };
+      return modalRef;
+    });
+    component.showKeyModal();
+    expect(modalShowSpy).toHaveBeenCalled();
+    // expect(s).toHaveBeenCalledWith( modalRef.componentInstance.setViewing);
+  });
+  it('should call showKeyModal when key selection is of type Swift', () => {
+    component.keysSelection.first = () => {
+      return { type: 'Swift', ref: { user: '', access_key: '', secret_key: '' } };
+    };
+    const modalShowSpy = spyOn(component['modalService'], 'show').and.callFake(() => {
+      modalRef = {
+        componentInstance: {
+          setValues: jest.fn(),
+          setViewing: jest.fn()
+        }
+      };
+      return modalRef;
+    });
+    component.showKeyModal();
+    expect(modalShowSpy).toHaveBeenCalled();
+    // expect(s).toHaveBeenCalledWith( modalRef.componentInstance.setViewing);
+  });
 });
index acb85a1a43ac7459200b0c83638c55b4a5dd11ec..0accf141c78530a42fad85752183788c076ea88d 100644 (file)
@@ -13,6 +13,7 @@ import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3
 import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
 import { CdTableAction } from '~/app/shared/models/cd-table-action';
 import { Permissions } from '~/app/shared/models/permissions';
+import { RgwRateLimitConfig } from '../models/rgw-rate-limit';
 
 @Component({
   selector: 'cd-rgw-user-details',
@@ -84,6 +85,11 @@ export class RgwUserDetailsComponent implements OnChanges, OnInit {
         _.extend(this.user, resp);
       });
 
+      // Load the user rate limit of the selected user.
+      this.rgwUserService.getUserRateLimit(this.user.uid).subscribe((resp: RgwRateLimitConfig) => {
+        _.extend(this.user, resp);
+      });
+
       // Process the keys.
       this.keys = [];
       if (this.user.keys) {
index 69e9b4ca29baf3025aca2096ff8350d424b85131..65bc0dee00b8d2082e343a754b8ab6bfe2dddbc2 100644 (file)
@@ -20,9 +20,9 @@
                    type="text"
                    formControlName="user_id"
                    [readonly]="editing">
-              <span class="invalid-feedback"
-                    *ngIf="userForm.showError('user_id', frm, 'required')"
-                    i18n>This field is required.</span>
+            <span class="invalid-feedback"
+                  *ngIf="userForm.showError('user_id', frm, 'required')"
+                  i18n>This field is required.</span>
             <span class="invalid-feedback"
                   *ngIf="userForm.showError('user_id', frm, 'pattern')"
                   i18n>The value is not valid.</span>
           </div>
         </div>
 
-          <!-- Show Tenant -->
-          <div class="form-group row">
-            <div class="cd-col-form-offset">
-              <div class="custom-control custom-checkbox">
-                <input class="custom-control-input"
-                       id="show_tenant"
-                       type="checkbox"
-                       (click)="updateFieldsWhenTenanted()"
-                       formControlName="show_tenant"
-                       [readonly]="true">
-                <label class="custom-control-label"
-                       for="show_tenant"
-                       i18n>Show Tenant</label>
-              </div>
+        <!-- Show Tenant -->
+        <div class="form-group row">
+          <div class="cd-col-form-offset">
+            <div class="custom-control custom-checkbox">
+              <input class="custom-control-input"
+                     id="show_tenant"
+                     type="checkbox"
+                     (click)="updateFieldsWhenTenanted()"
+                     formControlName="show_tenant"
+                     [readonly]="true">
+              <label class="custom-control-label spacing-03"
+                     for="show_tenant"
+                     i18n>Show Tenant</label>
             </div>
           </div>
+        </div>
 
         <!-- Tenant -->
         <div class="form-group row"
                      id="suspended"
                      type="checkbox"
                      formControlName="suspended">
-              <label class="custom-control-label"
+              <label class="custom-control-label spacing-03"
                      for="suspended"
                      i18n>Suspended</label>
               <cd-helper i18n>Suspending the user disables the user and subuser.</cd-helper>
                      id="system"
                      type="checkbox"
                      formControlName="system">
-              <label class="custom-control-label"
+              <label class="custom-control-label spacing-03"
                      for="system"
                      i18n>System user</label>
-              <cd-helper i18n>System users are distinct from regular users, they are used by the RGW service to perform administrative tasks, manage buckets and objects</cd-helper>
+              <cd-helper i18n>System users are distinct from regular users, they are used by the RGW service to perform
+                administrative tasks, manage buckets and objects</cd-helper>
             </div>
           </div>
         </div>
                        id="generate_key"
                        type="checkbox"
                        formControlName="generate_key">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="generate_key"
                        i18n>Auto-generate key</label>
               </div>
               </div>
               <span class="invalid-feedback"
                     *ngIf="userForm.showError('secret_key', frm, 'required')"
-                    i18n>This field is required.</span>
+                    i18n>This field
+                is required.</span>
             </div>
           </div>
         </fieldset>
                        id="user_quota_enabled"
                        type="checkbox"
                        formControlName="user_quota_enabled">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="user_quota_enabled"
                        i18n>Enabled</label>
               </div>
                        id="user_quota_max_size_unlimited"
                        type="checkbox"
                        formControlName="user_quota_max_size_unlimited">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="user_quota_max_size_unlimited"
                        i18n>Unlimited size</label>
               </div>
                        id="user_quota_max_objects_unlimited"
                        type="checkbox"
                        formControlName="user_quota_max_objects_unlimited">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="user_quota_max_objects_unlimited"
                        i18n>Unlimited objects</label>
               </div>
                        id="bucket_quota_enabled"
                        type="checkbox"
                        formControlName="bucket_quota_enabled">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="bucket_quota_enabled"
                        i18n>Enabled</label>
               </div>
                        id="bucket_quota_max_size_unlimited"
                        type="checkbox"
                        formControlName="bucket_quota_max_size_unlimited">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="bucket_quota_max_size_unlimited"
                        i18n>Unlimited size</label>
               </div>
                        id="bucket_quota_max_objects_unlimited"
                        type="checkbox"
                        formControlName="bucket_quota_max_objects_unlimited">
-                <label class="custom-control-label"
+                <label class="custom-control-label spacing-03"
                        for="bucket_quota_max_objects_unlimited"
                        i18n>Unlimited objects</label>
               </div>
                     i18n>This field is required.</span>
               <span class="invalid-feedback"
                     *ngIf="userForm.showError('bucket_quota_max_objects', frm, 'min')"
-                    i18n>The entered value must be >= 0.</span>
+                    i18n>Enter a positive number.</span>
             </div>
           </div>
         </fieldset>
+
+      <!-- Advanced Section -->
+      <cd-form-advanced-fieldset>
+        <!-- User Rate Limit -->
+        <cd-rgw-rate-limit [type]="'user'"
+                           [isEditing]="this.editing"
+                           [id]="uid"
+                           (rateLimitFormGroup)="rateLimitFormInit($event)">
+        </cd-rgw-rate-limit>
+      </cd-form-advanced-fieldset>
       </div>
 
       <div class="card-footer">
index 8b2c14172191569e7a9c4f0c5eca34e1a1b94f33..15b9e29320d1885b10f4daf118918504b6b647c9 100644 (file)
@@ -19,15 +19,19 @@ import { RgwUserCapability } from '../models/rgw-user-capability';
 import { RgwUserS3Key } from '../models/rgw-user-s3-key';
 import { RgwUserFormComponent } from './rgw-user-form.component';
 import { DUE_TIMER } from '~/app/shared/forms/cd-validators';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { RgwRateLimitComponent } from '../rgw-rate-limit/rgw-rate-limit.component';
+import { By } from '@angular/platform-browser';
 
 describe('RgwUserFormComponent', () => {
   let component: RgwUserFormComponent;
   let fixture: ComponentFixture<RgwUserFormComponent>;
   let rgwUserService: RgwUserService;
   let formHelper: FormHelper;
-
+  let modalRef: any;
+  let childComponent: any;
   configureTestBed({
-    declarations: [RgwUserFormComponent],
+    declarations: [RgwUserFormComponent, RgwRateLimitComponent],
     imports: [
       HttpClientTestingModule,
       ReactiveFormsModule,
@@ -39,12 +43,12 @@ describe('RgwUserFormComponent', () => {
     ]
   });
 
-  beforeEach(() => {
+  beforeEach(async () => {
     fixture = TestBed.createComponent(RgwUserFormComponent);
     component = fixture.componentInstance;
-    fixture.detectChanges();
     rgwUserService = TestBed.inject(RgwUserService);
     formHelper = new FormHelper(component.userForm);
+    await fixture.whenStable();
   });
 
   it('should create', () => {
@@ -176,9 +180,15 @@ describe('RgwUserFormComponent', () => {
   });
 
   describe('max buckets', () => {
+    beforeEach(() => {
+      fixture.detectChanges();
+      childComponent = fixture.debugElement.query(By.directive(RgwRateLimitComponent))
+        .componentInstance;
+    });
     it('disable creation (create)', () => {
       spyOn(rgwUserService, 'create');
       formHelper.setValue('max_buckets_mode', -1, true);
+      let spyRateLimit = jest.spyOn(childComponent, 'getRateLimitFormValue');
       component.onSubmit();
       expect(rgwUserService.create).toHaveBeenCalledWith({
         access_key: '',
@@ -191,12 +201,14 @@ describe('RgwUserFormComponent', () => {
         system: false,
         uid: null
       });
+      expect(spyRateLimit).toHaveBeenCalled();
     });
 
     it('disable creation (edit)', () => {
       spyOn(rgwUserService, 'update');
       component.editing = true;
       formHelper.setValue('max_buckets_mode', -1, true);
+      let spyRateLimit = jest.spyOn(childComponent, 'getRateLimitFormValue');
       component.onSubmit();
       expect(rgwUserService.update).toHaveBeenCalledWith(null, {
         display_name: null,
@@ -205,11 +217,13 @@ describe('RgwUserFormComponent', () => {
         suspended: false,
         system: false
       });
+      expect(spyRateLimit).toHaveBeenCalled();
     });
 
     it('unlimited buckets (create)', () => {
       spyOn(rgwUserService, 'create');
       formHelper.setValue('max_buckets_mode', 0, true);
+      let spyRateLimit = jest.spyOn(childComponent, 'getRateLimitFormValue');
       component.onSubmit();
       expect(rgwUserService.create).toHaveBeenCalledWith({
         access_key: '',
@@ -222,12 +236,14 @@ describe('RgwUserFormComponent', () => {
         system: false,
         uid: null
       });
+      expect(spyRateLimit).toHaveBeenCalled();
     });
 
     it('unlimited buckets (edit)', () => {
       spyOn(rgwUserService, 'update');
       component.editing = true;
       formHelper.setValue('max_buckets_mode', 0, true);
+      let spyRateLimit = jest.spyOn(childComponent, 'getRateLimitFormValue');
       component.onSubmit();
       expect(rgwUserService.update).toHaveBeenCalledWith(null, {
         display_name: null,
@@ -236,12 +252,14 @@ describe('RgwUserFormComponent', () => {
         suspended: false,
         system: false
       });
+      expect(spyRateLimit).toHaveBeenCalled();
     });
 
     it('custom (create)', () => {
       spyOn(rgwUserService, 'create');
       formHelper.setValue('max_buckets_mode', 1, true);
       formHelper.setValue('max_buckets', 100, true);
+      let spyRateLimit = jest.spyOn(childComponent, 'getRateLimitFormValue');
       component.onSubmit();
       expect(rgwUserService.create).toHaveBeenCalledWith({
         access_key: '',
@@ -254,6 +272,7 @@ describe('RgwUserFormComponent', () => {
         system: false,
         uid: null
       });
+      expect(spyRateLimit).toHaveBeenCalled();
     });
 
     it('custom (edit)', () => {
@@ -261,6 +280,7 @@ describe('RgwUserFormComponent', () => {
       component.editing = true;
       formHelper.setValue('max_buckets_mode', 1, true);
       formHelper.setValue('max_buckets', 100, true);
+      let spyRateLimit = jest.spyOn(childComponent, 'getRateLimitFormValue');
       component.onSubmit();
       expect(rgwUserService.update).toHaveBeenCalledWith(null, {
         display_name: null,
@@ -269,6 +289,7 @@ describe('RgwUserFormComponent', () => {
         suspended: false,
         system: false
       });
+      expect(spyRateLimit).toHaveBeenCalled();
     });
   });
 
@@ -279,6 +300,10 @@ describe('RgwUserFormComponent', () => {
       spyOn(TestBed.inject(Router), 'navigate').and.stub();
       notificationService = TestBed.inject(NotificationService);
       spyOn(notificationService, 'show');
+      fixture.detectChanges();
+      let childComponent = fixture.debugElement.query(By.directive(RgwRateLimitComponent))
+        .componentInstance;
+      jest.spyOn(childComponent, 'getRateLimitFormValue');
     });
 
     it('should be able to clear the mail field on update', () => {
@@ -344,4 +369,312 @@ describe('RgwUserFormComponent', () => {
       expect(capabilityButton.disabled).toBeFalsy();
     });
   });
+  it('should not modify max_buckets if mode is not "1"', () => {
+    formHelper.setValue('max_buckets', '', false);
+    component.onMaxBucketsModeChange('2');
+    const patchVal = spyOn(component.userForm, 'patchValue');
+    expect(patchVal).not.toHaveBeenCalled();
+  });
+
+  describe('updateFieldsWhenTenanted()', () => {
+    it('should reset the form when showTenant is falsy', () => {
+      component.showTenant = false;
+
+      component.previousTenant = 'true';
+
+      component.userForm.get('tenant').setValue('test1');
+      component.userForm.get('user_id').setValue('user-123');
+
+      component.updateFieldsWhenTenanted();
+      expect(component.userForm.get('user_id').untouched).toBeTruthy(); // user_id should be untouched
+      expect(component.userForm.get('tenant').value).toBe('true'); // tenant should be reset to
+    });
+  });
+
+  it('should call deletecapab', () => {
+    component.capabilities = [{ type: 'users', perm: 'read' }];
+    component.deleteCapability(0);
+    expect(component.capabilities.length).toBe(0);
+    expect(component.userForm.dirty).toBeTruthy();
+  });
+  it('should call deleteS3Key', () => {
+    component.s3Keys = [
+      { user: 'test5$test11', access_key: 'A009', secret_key: 'ABCKEY', generate_key: true }
+    ];
+    component.deleteS3Key(0);
+    expect(component.userForm.dirty).toBeTruthy();
+  });
+
+  it('should call showCapabilityModal', () => {
+    const modalShowSpy = spyOn(component['modalService'], 'show').and.callFake(() => {
+      modalRef = {
+        componentInstance: {
+          setEditing: jest.fn(),
+          setValues: jest.fn(),
+          setCapabilities: jest.fn(),
+          submitAction: { subscribe: jest.fn() }
+        }
+      };
+      return modalRef;
+    });
+    component.capabilities = [{ type: 'users', perm: 'read' }];
+    component.showCapabilityModal(0);
+    expect(modalShowSpy).toHaveBeenCalled();
+  });
+  it('should call showSwiftKeyModal', () => {
+    const modalShowSpy = spyOn(component['modalService'], 'show').and.callFake(() => {
+      modalRef = {
+        componentInstance: {
+          setValues: jest.fn()
+        }
+      };
+      return modalRef;
+    });
+    component.swiftKeys = [
+      { user: 'user1', secret_key: 'secret1' },
+      { user: 'user2', secret_key: 'secret2' }
+    ];
+    component.showSwiftKeyModal(0);
+    expect(modalShowSpy).toHaveBeenCalled();
+  });
+  it('should call showS3KeyModal', () => {
+    const modalShowSpy = spyOn(component['modalService'], 'show').and.callFake(() => {
+      modalRef = {
+        componentInstance: {
+          setValues: jest.fn(),
+          setViewing: jest.fn(),
+          setUserCandidates: jest.fn(),
+          submitAction: { subscribe: jest.fn() }
+        }
+      };
+      return modalRef;
+    });
+    component.s3Keys = [
+      { user: 'test5$test11', access_key: 'A009', secret_key: 'ABCKEY', generate_key: true }
+    ];
+    component.showS3KeyModal(0);
+    expect(modalShowSpy).toHaveBeenCalled();
+  });
+
+  it('should call _getS3KeyUserCandidates', () => {
+    spyOn(component, 'getUID').and.returnValue('mockUID');
+    component.s3Keys = [
+      { user: 'mockUID', access_key: 'test', secret_key: 'TestKey', generate_key: true }
+    ];
+    const keycandidates = component['_getS3KeyUserCandidates']();
+    const result = ['mockUID'];
+    expect(keycandidates).toEqual(result);
+  });
+
+  describe('test case for _getBucketQuotaArgs', () => {
+    it('should return correct result when quota values are specified', () => {
+      // Using patchValue to set form values
+      component.userForm.patchValue({
+        bucket_quota_enabled: true,
+        bucket_quota_max_size: 2048, // 2MB
+        bucket_quota_max_objects: 10000,
+        bucket_quota_max_size_unlimited: false, // Not unlimited
+        bucket_quota_max_objects_unlimited: false // Not unlimited
+      });
+      const formatterServiceSpy = jest.spyOn(FormatterService.prototype, 'toBytes');
+      // Mock the toBytes function to return 2048 (bytes)
+      formatterServiceSpy.mockReturnValue(2048); // Convert 2MB to bytes (2048 KB)
+
+      const result = component['_getBucketQuotaArgs']();
+
+      expect(result).toEqual({
+        quota_type: 'bucket',
+        enabled: true,
+        max_size_kb: '2', // 2048 bytes = 2KB
+        max_objects: 10000
+      });
+    });
+    it('should return correct result when quota values are specified', () => {
+      // Using patchValue to set form values
+      component.userForm.patchValue({
+        bucket_quota_enabled: true,
+        bucket_quota_max_size: 2048, // 2MB
+        bucket_quota_max_objects: 10000,
+        bucket_quota_max_size_unlimited: false, // Not unlimited
+        bucket_quota_max_objects_unlimited: false // Not unlimited
+      });
+
+      const formatterServiceSpy = jest.spyOn(FormatterService.prototype, 'toBytes');
+      // Mock the toBytes function to return 2048 (bytes)
+      formatterServiceSpy.mockReturnValue(2048); // Convert 2MB to bytes (2048 KB)
+
+      const result = component['_getBucketQuotaArgs']();
+
+      expect(result).toEqual({
+        quota_type: 'bucket',
+        enabled: true,
+        max_size_kb: '2', // 2048 bytes = 2KB
+        max_objects: 10000
+      });
+    });
+    it('should return default values for unlimited size and objects', () => {
+      component.userForm.patchValue({
+        bucket_quota_enabled: true,
+        bucket_quota_max_size: 2048, // 2MB
+        bucket_quota_max_objects: 10000,
+        bucket_quota_max_size_unlimited: true, // Unlimited
+        bucket_quota_max_objects_unlimited: true // Unlimited
+      });
+
+      const formatterServiceSpy = jest.spyOn(FormatterService.prototype, 'toBytes');
+      formatterServiceSpy.mockReturnValue(2048); // Convert 2MB to bytes (2048 KB)
+
+      const result = component['_getBucketQuotaArgs']();
+
+      expect(result).toEqual({
+        quota_type: 'bucket',
+        enabled: true,
+        max_size_kb: -1, // Default value when unlimited
+        max_objects: -1 // Default value when unlimited
+      });
+    });
+  });
+  describe('test case for _getUserQuotaArgs', () => {
+    it('should return quota info with default values when no quota is set', () => {
+      // Use patchValue to set form values
+      component.userForm.patchValue({
+        user_quota_enabled: true,
+        user_quota_max_size_unlimited: true,
+        user_quota_max_objects_unlimited: true,
+        user_quota_max_size: null,
+        user_quota_max_objects: null
+      });
+
+      // Call the method
+      const result = component._getUserQuotaArgs();
+
+      // Assertions
+      expect(result).toEqual({
+        quota_type: 'user',
+        enabled: true,
+        max_size_kb: -1,
+        max_objects: -1
+      });
+    });
+
+    it('should calculate max_size_kb when quota size is specified and not unlimited', () => {
+      // Use patchValue to set form values
+      component.userForm.patchValue({
+        user_quota_enabled: true,
+        user_quota_max_size_unlimited: false,
+        user_quota_max_objects_unlimited: true,
+        user_quota_max_size: 2048, // Example quota size in KB
+        user_quota_max_objects: null
+      });
+
+      const toBytesSpy = jest
+        .spyOn(FormatterService.prototype, 'toBytes')
+        .mockReturnValue(2048 * 1024);
+
+      const result = component._getUserQuotaArgs();
+      expect(toBytesSpy).toHaveBeenCalledWith(2048);
+      expect(result).toEqual({
+        quota_type: 'user',
+        enabled: true,
+        max_size_kb: '2048', // Expect the converted KB value
+        max_objects: -1
+      });
+    });
+
+    it('should set max_objects when quota is specified and not unlimited', () => {
+      // Use patchValue to set form values
+      component.userForm.patchValue({
+        user_quota_enabled: true,
+        user_quota_max_size_unlimited: true,
+        user_quota_max_objects_unlimited: false,
+        user_quota_max_size: null,
+        user_quota_max_objects: 1000 // Example quota size
+      });
+
+      const result = component._getUserQuotaArgs();
+
+      expect(result).toEqual({
+        quota_type: 'user',
+        enabled: true,
+        max_size_kb: -1,
+        max_objects: 1000
+      });
+    });
+  });
+
+  it('should call showSubuserModal', () => {
+    const modalShowSpy = spyOn(component['modalService'], 'show').and.callFake(() => {
+      modalRef = {
+        componentInstance: {
+          setValues: jest.fn(),
+          setViewing: jest.fn(),
+          setEditing: jest.fn(),
+          setUserCandidates: jest.fn(),
+          submitAction: { subscribe: jest.fn() }
+        }
+      };
+      return modalRef;
+    });
+    component.subusers = [
+      { id: 'test', permissions: 'true', generate_secret: true, secret_key: '' }
+    ];
+    //  component.s3Keys= [{user: 'test5$test11', access_key: 'A009', secret_key: 'ABCKEY', generate_key: true}];
+    component.showSubuserModal(0);
+    expect(modalShowSpy).toHaveBeenCalled();
+  });
+  describe('test case for showSubuserModal', () => {
+    it('should handle "Edit" scenario when index is provided', () => {
+      let index = 0;
+      component.subusers = [
+        { id: 'test', permissions: 'true', generate_secret: true, secret_key: '' }
+      ];
+      let spy = spyOn(component['modalService'], 'show').and.callFake(() => {
+        return (modalRef = {
+          componentInstance: {
+            setEditing: jest.fn(),
+            setValues: jest.fn(),
+            setCapabilities: jest.fn(),
+            setSubusers: jest.fn(),
+            setUserCandidates: jest.fn(),
+            submitAction: { subscribe: jest.fn() }
+          }
+        });
+      });
+      spyOn(component, 'getUID').and.returnValue('dashboard');
+      component.showSubuserModal(index);
+      expect(spy).toHaveBeenCalledTimes(1);
+      expect(modalRef.componentInstance.setEditing).toHaveBeenCalledTimes(1);
+      expect(modalRef.componentInstance.setValues).toHaveBeenCalledWith(
+        'dashboard',
+        component.subusers[index].id,
+        component.subusers[index].permissions
+      );
+      expect(modalRef.componentInstance.submitAction.subscribe).toHaveBeenCalled();
+    });
+
+    it('should handle "Add" scenario when index is not provided', () => {
+      let spy = spyOn(component['modalService'], 'show').and.callFake(() => {
+        return (modalRef = {
+          componentInstance: {
+            setEditing: jest.fn(),
+            setValues: jest.fn(),
+            setCapabilities: jest.fn(),
+            setSubusers: jest.fn(),
+            setUserCandidates: jest.fn(),
+            submitAction: { subscribe: jest.fn() }
+          }
+        });
+      });
+      component.subusers = [
+        { id: 'test', permissions: 'true', generate_secret: true, secret_key: '' }
+      ];
+      spyOn(component, 'getUID').and.returnValue('dashboard');
+      component.showSubuserModal();
+      expect(spy).toHaveBeenCalledTimes(1);
+      expect(modalRef.componentInstance.setEditing).toHaveBeenCalledWith(false);
+      expect(modalRef.componentInstance.setValues).toHaveBeenCalledWith('dashboard');
+      expect(modalRef.componentInstance.setSubusers).toHaveBeenCalledWith(component.subusers);
+      expect(modalRef.componentInstance.submitAction.subscribe).toHaveBeenCalled();
+    });
+  });
 });
index ec23ca0f7ba110da5f3b2943dd17be690b51c2eb..5973d6a658e2c1074742f348a9774ea734f02b4b 100644 (file)
@@ -1,5 +1,5 @@
-import { Component, OnInit } from '@angular/core';
-import { AbstractControl, ValidationErrors, Validators } from '@angular/forms';
+import { Component, OnInit, ViewChild } from '@angular/core';
+import { AbstractControl, FormGroup, ValidationErrors, Validators } from '@angular/forms';
 import { ActivatedRoute, Router } from '@angular/router';
 
 import _ from 'lodash';
@@ -12,7 +12,7 @@ import { NotificationType } from '~/app/shared/enum/notification-type.enum';
 import { CdForm } from '~/app/shared/forms/cd-form';
 import { CdFormBuilder } from '~/app/shared/forms/cd-form-builder';
 import { CdFormGroup } from '~/app/shared/forms/cd-form-group';
-import { CdValidators, isEmptyInputValue } from '~/app/shared/forms/cd-validators';
+import { CdValidators } from '~/app/shared/forms/cd-validators';
 import { FormatterService } from '~/app/shared/services/formatter.service';
 import { ModalService } from '~/app/shared/services/modal.service';
 import { NotificationService } from '~/app/shared/services/notification.service';
@@ -25,6 +25,8 @@ import { RgwUserCapabilityModalComponent } from '../rgw-user-capability-modal/rg
 import { RgwUserS3KeyModalComponent } from '../rgw-user-s3-key-modal/rgw-user-s3-key-modal.component';
 import { RgwUserSubuserModalComponent } from '../rgw-user-subuser-modal/rgw-user-subuser-modal.component';
 import { RgwUserSwiftKeyModalComponent } from '../rgw-user-swift-key-modal/rgw-user-swift-key-modal.component';
+import { RgwRateLimitComponent } from '../rgw-rate-limit/rgw-rate-limit.component';
+import { RgwRateLimitConfig } from '../models/rgw-rate-limit';
 
 @Component({
   selector: 'cd-rgw-user-form',
@@ -40,7 +42,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
   s3Keys: RgwUserS3Key[] = [];
   swiftKeys: RgwUserSwiftKey[] = [];
   capabilities: RgwUserCapability[] = [];
-
+  uid: string;
   action: string;
   resource: string;
   subuserLabel: string;
@@ -49,6 +51,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
   usernameExists: boolean;
   showTenant = false;
   previousTenant: string = null;
+  @ViewChild(RgwRateLimitComponent, { static: false }) rateLimitComponent!: RgwRateLimitComponent;
 
   constructor(
     private formBuilder: CdFormBuilder,
@@ -183,6 +186,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
       const observables = [];
       observables.push(this.rgwUserService.get(uid));
       observables.push(this.rgwUserService.getQuota(uid));
+      observables.push(this.rgwUserService.getUserRateLimit(uid));
       observableForkJoin(observables).subscribe(
         (resp: any[]) => {
           // Get the default values.
@@ -222,6 +226,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
               value[type + '_quota_max_objects'] = quota.max_objects;
             }
           });
+
           // Merge with default values.
           value = _.merge(defaults, value);
           // Update the form.
@@ -242,7 +247,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
             }
           });
           this.capabilities = resp[0].caps;
-
+          this.uid = this.getUID();
           this.loadingReady();
         },
         () => {
@@ -252,40 +257,52 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
     });
   }
 
+  rateLimitFormInit(rateLimitForm: FormGroup) {
+    this.userForm.addControl('rateLimit', rateLimitForm);
+  }
+
   goToListView() {
     this.router.navigate(['/rgw/user']);
   }
 
   onSubmit() {
+    this.uid = this.getUID();
     let notificationTitle: string;
     // Exit immediately if the form isn't dirty.
-    if (this.userForm.pristine) {
+    if (this.userForm.pristine && this.rateLimitComponent.form.pristine) {
       this.goToListView();
       return;
     }
-    const uid = this.getUID();
     if (this.editing) {
       // Edit
       if (this._isGeneralDirty()) {
         const args = this._getUpdateArgs();
-        this.submitObservables.push(this.rgwUserService.update(uid, args));
+        this.submitObservables.push(this.rgwUserService.update(this.uid, args));
       }
-      notificationTitle = $localize`Updated Object Gateway user '${uid}'`;
+      notificationTitle = $localize`Updated Object Gateway user '${this.uid}'`;
     } else {
       // Add
       const args = this._getCreateArgs();
       this.submitObservables.push(this.rgwUserService.create(args));
-      notificationTitle = $localize`Created Object Gateway user '${uid}'`;
+      notificationTitle = $localize`Created Object Gateway user '${this.uid}'`;
     }
     // Check if user quota has been modified.
     if (this._isUserQuotaDirty()) {
       const userQuotaArgs = this._getUserQuotaArgs();
-      this.submitObservables.push(this.rgwUserService.updateQuota(uid, userQuotaArgs));
+      this.submitObservables.push(this.rgwUserService.updateQuota(this.uid, userQuotaArgs));
     }
     // Check if bucket quota has been modified.
     if (this._isBucketQuotaDirty()) {
       const bucketQuotaArgs = this._getBucketQuotaArgs();
-      this.submitObservables.push(this.rgwUserService.updateQuota(uid, bucketQuotaArgs));
+      this.submitObservables.push(this.rgwUserService.updateQuota(this.uid, bucketQuotaArgs));
+    }
+
+    // Check if user ratelimit has been modified.
+    const ratelimitvalue: RgwRateLimitConfig = this.rateLimitComponent.getRateLimitFormValue();
+    if (!!ratelimitvalue) {
+      this.submitObservables.push(
+        this.rgwUserService.updateUserRateLimit(this.userForm.getValue('user_id'), ratelimitvalue)
+      );
     }
     // Finally execute all observables one by one in serial.
     observableConcat(...this.submitObservables).subscribe({
@@ -325,17 +342,12 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
    * Validate the quota maximum size, e.g. 1096, 1K, 30M or 1.9MiB.
    */
   quotaMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
-    if (isEmptyInputValue(control.value)) {
-      return null;
-    }
-    const m = RegExp('^(\\d+(\\.\\d+)?)\\s*(B|K(B|iB)?|M(B|iB)?|G(B|iB)?|T(B|iB)?)?$', 'i').exec(
-      control.value
+    return new FormatterService().performValidation(
+      control,
+      '^(\\d+(\\.\\d+)?)\\s*(B|K(B|iB)?|M(B|iB)?|G(B|iB)?|T(B|iB)?)?$',
+      { quotaMaxSize: true },
+      'quota'
     );
-    if (m === null) {
-      return { quotaMaxSize: true };
-    }
-    const bytes = new FormatterService().toBytes(control.value);
-    return bytes < 1024 ? { quotaMaxSize: true } : null;
   }
 
   /**
@@ -680,7 +692,7 @@ export class RgwUserFormComponent extends CdForm implements OnInit {
    * Helper function to get the arguments for the API request when the user
    * quota configuration has been modified.
    */
-  private _getUserQuotaArgs(): Record<string, any> {
+  _getUserQuotaArgs(): Record<string, any> {
     const result = {
       quota_type: 'user',
       enabled: this.userForm.getValue('user_quota_enabled'),
index fad630b50dcc62fbfcfca6e2fb4682d550f4f82d..8a22cbe1ec7f08e7c7284dd4f2519ec07e7dc956 100644 (file)
@@ -92,6 +92,8 @@ import { RgwStorageClassDetailsComponent } from './rgw-storage-class-details/rgw
 import { RgwStorageClassFormComponent } from './rgw-storage-class-form/rgw-storage-class-form.component';
 import { RgwBucketTieringFormComponent } from './rgw-bucket-tiering-form/rgw-bucket-tiering-form.component';
 import { RgwBucketLifecycleListComponent } from './rgw-bucket-lifecycle-list/rgw-bucket-lifecycle-list.component';
+import { RgwRateLimitComponent } from './rgw-rate-limit/rgw-rate-limit.component';
+import { RgwRateLimitDetailsComponent } from './rgw-rate-limit-details/rgw-rate-limit-details.component';
 
 @NgModule({
   imports: [
@@ -143,6 +145,7 @@ import { RgwBucketLifecycleListComponent } from './rgw-bucket-lifecycle-list/rgw
     RgwStorageClassListComponent
   ],
   declarations: [
+    RgwRateLimitComponent,
     RgwDaemonListComponent,
     RgwDaemonDetailsComponent,
     RgwBucketFormComponent,
@@ -190,7 +193,8 @@ import { RgwBucketLifecycleListComponent } from './rgw-bucket-lifecycle-list/rgw
     RgwStorageClassDetailsComponent,
     RgwStorageClassFormComponent,
     RgwBucketTieringFormComponent,
-    RgwBucketLifecycleListComponent
+    RgwBucketLifecycleListComponent,
+    RgwRateLimitDetailsComponent
   ],
   providers: [TitleCasePipe]
 })
index 621919da59c49768372c451a78fc20c27badb16d..50ceee17b53b3d31d0ef2899fa20df33de719d7e 100644 (file)
@@ -5,6 +5,7 @@ import _ from 'lodash';
 import { BehaviorSubject, of as observableOf } from 'rxjs';
 import { catchError, map, mapTo } from 'rxjs/operators';
 import { Bucket } from '~/app/ceph/rgw/models/rgw-bucket';
+import { RgwRateLimitConfig } from '~/app/ceph/rgw/models/rgw-rate-limit';
 
 import { ApiClient } from '~/app/shared/api/api-client';
 import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
@@ -293,4 +294,13 @@ export class RgwBucketService extends ApiClient {
       return this.http.get(`${this.url}/lifecycle`, { params: params });
     });
   }
+  updateBucketRateLimit(bid: string, bucketRateLimitArgs: RgwRateLimitConfig) {
+    return this.http.put(`${this.url}/${bid}/ratelimit`, bucketRateLimitArgs);
+  }
+  getBucketRateLimit(uid: string) {
+    return this.http.get(`${this.url}/${uid}/ratelimit`);
+  }
+  getGlobalBucketRateLimit() {
+    return this.http.get(`${this.url}/ratelimit`);
+  }
 }
index 66167bcabbd0777ff89778b0b04d778f99ed89ae..ecedcdc4e4152063904e1117d7b61fe5e265da00 100644 (file)
@@ -4,6 +4,7 @@ import { Injectable } from '@angular/core';
 import _ from 'lodash';
 import { forkJoin as observableForkJoin, Observable, of as observableOf } from 'rxjs';
 import { catchError, mapTo, mergeMap } from 'rxjs/operators';
+import { RgwRateLimitConfig } from '~/app/ceph/rgw/models/rgw-rate-limit';
 
 import { RgwDaemonService } from '~/app/shared/api/rgw-daemon.service';
 import { cdEncode } from '~/app/shared/decorators/cd-encode';
@@ -176,4 +177,15 @@ export class RgwUserService {
       })
     );
   }
+
+  updateUserRateLimit(uid: string, rateLimitArgs: RgwRateLimitConfig) {
+    return this.http.put(`${this.url}/${uid}/ratelimit`, rateLimitArgs);
+  }
+
+  getUserRateLimit(uid: string) {
+    return this.http.get(`${this.url}/${uid}/ratelimit`);
+  }
+  getGlobalUserRateLimit() {
+    return this.http.get(`${this.url}/ratelimit`);
+  }
 }
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-minute.directive.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-minute.directive.spec.ts
new file mode 100644 (file)
index 0000000..facec2d
--- /dev/null
@@ -0,0 +1,12 @@
+import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.directive';
+
+export class MockElementRef {
+  nativeElement: {};
+}
+
+describe('DimlessBinaryPerMinuteDirective', () => {
+  it('should create an instance', () => {
+    const directive = new DimlessBinaryPerMinuteDirective(new MockElementRef(), null, null, null);
+    expect(directive).toBeTruthy();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-minute.directive.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/directives/dimless-binary-per-minute.directive.ts
new file mode 100644 (file)
index 0000000..f4727c7
--- /dev/null
@@ -0,0 +1,136 @@
+import {
+  Directive,
+  ElementRef,
+  EventEmitter,
+  HostListener,
+  Input,
+  OnInit,
+  Output
+} from '@angular/core';
+import { NgControl } from '@angular/forms';
+
+import _ from 'lodash';
+import { FormatterService } from '../services/formatter.service';
+import { DimlessBinaryPerMinutePipe } from '../pipes/dimless-binary-per-minute.pipe';
+
+@Directive({
+  selector: '[cdDimlessBinaryPerMinute]'
+})
+export class DimlessBinaryPerMinuteDirective implements OnInit {
+  @Output()
+  ngModelChange: EventEmitter<any> = new EventEmitter();
+
+  /**
+   * Event emitter for letting this directive know that the data has (asynchronously) been loaded
+   * and the value needs to be adapted by this directive.
+   */
+  @Input()
+  ngDataReady: EventEmitter<any>;
+
+  /**
+   * Minimum size in bytes.
+   * If user enter a value lower than <minBytes>,
+   * the model will automatically be update to <minBytes>.
+   *
+   * If <roundPower> is used, this value should be a power of <roundPower>.
+   *
+   * Example:
+   *   Given minBytes=4096 (4KiB), if user type 1KiB, then model will be updated to 4KiB
+   */
+  @Input()
+  minBytes: number;
+
+  /**
+   * Maximum size in bytes.
+   * If user enter a value greater than <maxBytes>,
+   * the model will automatically be update to <maxBytes>.
+   *
+   * If <roundPower> is used, this value should be a power of <roundPower>.
+   *
+   * Example:
+   *   Given maxBytes=3145728 (3MiB), if user type 4MiB, then model will be updated to 3MiB
+   */
+  @Input()
+  maxBytes: number;
+
+  /**
+   * Value will be rounded up the nearest power of <roundPower>
+   *
+   * Example:
+   *   Given roundPower=2, if user type 7KiB, then model will be updated to 8KiB
+   *   Given roundPower=2, if user type 5KiB, then model will be updated to 4KiB
+   */
+  @Input()
+  roundPower: number;
+
+  /**
+   * Default unit that should be used when user do not type a unit.
+   * By default, "MiB" will be used.
+   *
+   * Example:
+   *   Given defaultUnit=null, if user type 7, then model will be updated to 7MiB
+   *   Given defaultUnit=k, if user type 7, then model will be updated to 7KiB
+   */
+  @Input()
+  defaultUnit: string;
+
+  private el: HTMLInputElement;
+
+  constructor(
+    private elementRef: ElementRef,
+    private control: NgControl,
+    private dimlessBinaryPerMinutePipe: DimlessBinaryPerMinutePipe,
+    private formatter: FormatterService
+  ) {
+    this.el = this.elementRef.nativeElement;
+  }
+
+  ngOnInit() {
+    this.setValue(this.el.value);
+    if (this.ngDataReady) {
+      this.ngDataReady.subscribe(() => this.setValue(this.el.value));
+    }
+  }
+
+  setValue(value: string) {
+    if (value === '') {
+      this.ngModelChange.emit(value);
+      this.control.control.setValue(value);
+      return;
+    }
+    if (/^[\d.]+$/.test(value)) {
+      value += this.defaultUnit || 'm';
+    }
+    const size = this.formatter.toBytes(value, 0);
+    const roundedSize = this.round(size);
+    this.el.value = this.dimlessBinaryPerMinutePipe.transform(roundedSize);
+    if (size !== null) {
+      this.ngModelChange.emit(this.el.value);
+      this.control.control.setValue(this.el.value);
+    } else {
+      this.ngModelChange.emit(null);
+      this.control.control.setValue(null);
+    }
+  }
+
+  round(size: number) {
+    if (size !== null && size !== 0) {
+      if (!_.isUndefined(this.minBytes) && size < this.minBytes) {
+        return this.minBytes;
+      }
+      if (!_.isUndefined(this.maxBytes) && size > this.maxBytes) {
+        return this.maxBytes;
+      }
+      if (!_.isUndefined(this.roundPower)) {
+        const power = Math.round(Math.log(size) / Math.log(this.roundPower));
+        return Math.pow(this.roundPower, power);
+      }
+    }
+    return size;
+  }
+
+  @HostListener('blur', ['$event.target.value'])
+  onBlur(value: string) {
+    this.setValue(value);
+  }
+}
index c797dbdcfec840ed6e8c523d4a468edd16318f60..5d3f93b956447d811e1da6e9dcb96ab84b29f221 100644 (file)
@@ -18,6 +18,7 @@ import { TrimDirective } from './trim.directive';
 import { RequiredFieldDirective } from './required-field.directive';
 import { ReactiveFormsModule } from '@angular/forms';
 import { OptionalFieldDirective } from './optional-field.directive';
+import { DimlessBinaryPerMinuteDirective } from './dimless-binary-per-minute.directive';
 
 @NgModule({
   imports: [ReactiveFormsModule],
@@ -38,7 +39,8 @@ import { OptionalFieldDirective } from './optional-field.directive';
     CdFormValidationDirective,
     AuthStorageDirective,
     RequiredFieldDirective,
-    OptionalFieldDirective
+    OptionalFieldDirective,
+    DimlessBinaryPerMinuteDirective
   ],
   exports: [
     AutofocusDirective,
@@ -57,7 +59,8 @@ import { OptionalFieldDirective } from './optional-field.directive';
     CdFormValidationDirective,
     AuthStorageDirective,
     RequiredFieldDirective,
-    OptionalFieldDirective
+    OptionalFieldDirective,
+    DimlessBinaryPerMinuteDirective
   ]
 })
 export class DirectivesModule {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-minute.pipe.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/pipes/dimless-binary-per-minute.pipe.ts
new file mode 100644 (file)
index 0000000..7ba0657
--- /dev/null
@@ -0,0 +1,19 @@
+import { Pipe, PipeTransform } from '@angular/core';
+
+import { FormatterService } from '../services/formatter.service';
+
+@Pipe({
+  name: 'dimlessBinaryPerMinute'
+})
+export class DimlessBinaryPerMinutePipe implements PipeTransform {
+  constructor(private formatter: FormatterService) {}
+
+  transform(value: any, decimals: number = 1): any {
+    return this.formatter.format_number(
+      value,
+      1024,
+      ['B/m', 'KiB/m', 'MiB/m', 'GiB/m', 'TiB/m', 'PiB/m', 'EiB/m', 'ZiB/m', 'YiB/m'],
+      decimals
+    );
+  }
+}
index d08132f4120c53cde330efd149514d279e68c4d4..bab30d054236f77544b454616a2ca5c2bf479910 100755 (executable)
@@ -41,6 +41,7 @@ import { PluralizePipe } from './pluralize.pipe';
 import { XmlPipe } from './xml.pipe';
 import { MbpersecondPipe } from './mbpersecond.pipe';
 import { PipeFunctionPipe } from './pipe-function.pipe';
+import { DimlessBinaryPerMinutePipe } from './dimless-binary-per-minute.pipe';
 
 @NgModule({
   imports: [CommonModule],
@@ -84,7 +85,8 @@ import { PipeFunctionPipe } from './pipe-function.pipe';
     PluralizePipe,
     XmlPipe,
     MbpersecondPipe,
-    PipeFunctionPipe
+    PipeFunctionPipe,
+    DimlessBinaryPerMinutePipe
   ],
   exports: [
     ArrayPipe,
@@ -126,7 +128,8 @@ import { PipeFunctionPipe } from './pipe-function.pipe';
     PluralizePipe,
     XmlPipe,
     MbpersecondPipe,
-    PipeFunctionPipe
+    PipeFunctionPipe,
+    DimlessBinaryPerMinutePipe
   ],
   providers: [
     ArrayPipe,
@@ -159,7 +162,8 @@ import { PipeFunctionPipe } from './pipe-function.pipe';
     MdsSummaryPipe,
     OsdSummaryPipe,
     OctalToHumanReadablePipe,
-    MbpersecondPipe
+    MbpersecondPipe,
+    DimlessBinaryPerMinutePipe
   ]
 })
 export class PipesModule {}
index c1ad14b474231ad6f32aaff578239d91eae176a3..011fefd734214a753d47d48e7f61fc4d58ae29bf 100644 (file)
@@ -1,6 +1,7 @@
 import { Injectable } from '@angular/core';
-
+import { AbstractControl, ValidationErrors } from '@angular/forms';
 import _ from 'lodash';
+import { isEmptyInputValue } from '../forms/cd-validators';
 
 @Injectable({
   providedIn: 'root'
@@ -85,13 +86,16 @@ export class FormatterService {
   toBytes(value: string, error_value: number = null): number | null {
     const base = 1024;
     const units = ['b', 'k', 'm', 'g', 't', 'p', 'e', 'z', 'y'];
-    const m = RegExp('^(\\d+(.\\d+)?) ?([' + units.join('') + ']?(b|ib|B/s)?)?$', 'i').exec(value);
-    if (m === null) {
+    const bytesRegexMatch = RegExp(
+      '^(\\d+(.\\d+)?) ?([' + units.join('') + ']?(b|ib|B/s|B/m|iB/m)?)?$',
+      'i'
+    ).exec(value);
+    if (bytesRegexMatch === null) {
       return error_value;
     }
-    let bytes = parseFloat(m[1]);
-    if (_.isString(m[3])) {
-      bytes = bytes * Math.pow(base, units.indexOf(m[3].toLowerCase()[0]));
+    let bytes = parseFloat(bytesRegexMatch[1]);
+    if (_.isString(bytesRegexMatch[3])) {
+      bytes = bytes * Math.pow(base, units.indexOf(bytesRegexMatch[3].toLowerCase()[0]));
     }
     return Math.round(bytes);
   }
@@ -141,4 +145,38 @@ export class FormatterService {
     }
     return octalMode;
   }
+  /**
+   * Validate the input maximum size as per regrex passed.
+   */
+  performValidation(
+    control: AbstractControl,
+    regex: string,
+    errorObject: object,
+    type?: string
+  ): ValidationErrors | null {
+    if (isEmptyInputValue(control.value)) {
+      return null;
+    }
+    const matchResult = RegExp(regex, 'i').exec(control.value);
+    if (matchResult === null) {
+      return errorObject;
+    }
+    if (type == 'quota') {
+      const bytes = new FormatterService().toBytes(control.value);
+      return bytes < 1024 ? errorObject : null;
+    }
+    return null;
+  }
+
+  iopmMaxSizeValidator(control: AbstractControl): ValidationErrors | null {
+    const pattern = /^\s*(\d+)$/i;
+    const testResult = pattern.exec(control.value);
+    if (isEmptyInputValue(control.value)) {
+      return null;
+    }
+    if (testResult == null) {
+      return { rateOpsMaxSize: true };
+    }
+    return control.value.toString()?.length > 18 ? { rateOpsMaxSize: true } : null;
+  }
 }
old mode 100644 (file)
new mode 100755 (executable)
index dc2ccc3..9ea8b51
@@ -11635,6 +11635,29 @@ paths:
       - jwt: []
       tags:
       - RgwBucket
+  /api/rgw/bucket/ratelimit:
+    get:
+      parameters: []
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Get the bucket global rate limit
+      tags:
+      - RgwBucket
   /api/rgw/bucket/setEncryptionConfig:
     put:
       parameters: []
@@ -11845,6 +11868,88 @@ paths:
       - jwt: []
       tags:
       - RgwBucket
+  /api/rgw/bucket/{uid}/ratelimit:
+    get:
+      parameters:
+      - in: path
+        name: uid
+        required: true
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Get the bucket rate limit
+      tags:
+      - RgwBucket
+    put:
+      parameters:
+      - in: path
+        name: uid
+        required: true
+        schema:
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                enabled:
+                  type: string
+                max_read_bytes:
+                  type: string
+                max_read_ops:
+                  type: string
+                max_write_bytes:
+                  type: string
+                max_write_ops:
+                  type: string
+              required:
+              - enabled
+              - max_read_ops
+              - max_write_ops
+              - max_read_bytes
+              - max_write_bytes
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Update the bucket rate limit
+      tags:
+      - RgwBucket
   /api/rgw/daemon:
     get:
       parameters: []
@@ -12960,6 +13065,29 @@ paths:
       - jwt: []
       tags:
       - RgwUser
+  /api/rgw/user/ratelimit:
+    get:
+      parameters: []
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Get the user global rate limit
+      tags:
+      - RgwUser
   /api/rgw/user/{uid}:
     delete:
       parameters:
@@ -13362,6 +13490,87 @@ paths:
       - jwt: []
       tags:
       - RgwUser
+  /api/rgw/user/{uid}/ratelimit:
+    get:
+      parameters:
+      - in: path
+        name: uid
+        required: true
+        schema:
+          type: string
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: OK
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Get the user rate limit
+      tags:
+      - RgwUser
+    put:
+      parameters:
+      - in: path
+        name: uid
+        required: true
+        schema:
+          type: string
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                enabled:
+                  default: false
+                  type: boolean
+                max_read_bytes:
+                  default: 0
+                  type: integer
+                max_read_ops:
+                  default: 0
+                  type: integer
+                max_write_bytes:
+                  default: 0
+                  type: integer
+                max_write_ops:
+                  default: 0
+                  type: integer
+              type: object
+      responses:
+        '200':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/vnd.ceph.api.v1.0+json:
+              type: object
+          description: Operation is still executing. Please check the task queue.
+        '400':
+          description: Operation exception. Please check the response body for details.
+        '401':
+          description: Unauthenticated access. Please login first.
+        '403':
+          description: Unauthorized access. Please check your permissions.
+        '500':
+          description: Unexpected error. Please check the response body for the stack
+            trace.
+      security:
+      - jwt: []
+      summary: Update the user rate limit
+      tags:
+      - RgwUser
   /api/rgw/user/{uid}/subuser:
     post:
       parameters:
index 1b0c05feed4489998c37fb4ad29a297c81c0bd99..927d19e3ea8502b769f718adc7b062bee6144dfb 100755 (executable)
@@ -1373,6 +1373,66 @@ class RgwMultisiteAutomation:
             time.sleep(5)
 
 
+class RgwRateLimit:
+    def get_global_rateLimit(self):
+        rate_limit_cmd = ['global', 'ratelimit', 'get']
+        try:
+            exit_code, out, err = mgr.send_rgwadmin_command(rate_limit_cmd)
+            if exit_code > 0:
+                raise DashboardException(f'Unable to get rate limit: {err}',
+                                         http_status_code=500, component='rgw')
+            return out
+        except SubprocessError as error:
+            raise DashboardException(error, http_status_code=500, component='rgw')
+
+    def get_rateLimit(self, scope: str, name: str):
+        rate_limit_cmd = ['ratelimit', 'get', '--ratelimit-scope', scope]
+        if scope == 'user':
+            rate_limit_cmd.extend(['--uid', name])
+        if scope == 'bucket':
+            rate_limit_cmd.extend(['--bucket', name])
+        try:
+            exit_code, out, err = mgr.send_rgwadmin_command(rate_limit_cmd)
+            if exit_code > 0:
+                raise DashboardException(f'Unable to get rate limit: {err}',
+                                         http_status_code=500, component='rgw')
+            return out
+        except SubprocessError as error:
+            raise DashboardException(error, http_status_code=500, component='rgw')
+
+    def set_rateLimit(self, scope: str, enabled: bool, name: str,
+                      max_read_ops: int, max_write_ops: int,
+                      max_read_bytes: int, max_write_bytes: int):
+        enabled = str(enabled)
+        rgw_rate_limit_cmd = ['ratelimit', 'set', '--ratelimit-scope', scope,
+                              '--max-read-ops', str(max_read_ops), '--max-write-ops',
+                              str(max_write_ops), '--max-write-bytes', str(max_write_bytes),
+                              '--max-read-bytes', str(max_read_bytes)]
+
+        rgw_rate_limit_enable_cmd = ['ratelimit', 'enable' if enabled == 'True' else 'disable',
+                                     '--ratelimit-scope', scope]
+        if scope == 'user':
+            rgw_rate_limit_cmd.extend(['--uid', name])
+            rgw_rate_limit_enable_cmd.extend(['--uid', name])
+
+        if scope == 'bucket':
+            rgw_rate_limit_cmd.extend(['--bucket', name, ])
+            rgw_rate_limit_enable_cmd.extend(['--bucket', name])
+        try:
+            if enabled == 'True':
+                exit_code, _, err = mgr.send_rgwadmin_command(rgw_rate_limit_cmd)
+                if exit_code > 0:
+                    raise DashboardException(f'Unable to set rate limit: {err}',
+                                             http_status_code=500, component='rgw')
+            exit_code1, _, err = mgr.send_rgwadmin_command(rgw_rate_limit_enable_cmd)
+
+            if exit_code1 > 0:
+                raise DashboardException(f'Unable to enable rate limit: {err}',
+                                         http_status_code=500, component='rgw')
+        except SubprocessError as error:
+            raise DashboardException(error, http_status_code=500, component='rgw')
+
+
 class RgwMultisite:
     def migrate_to_multisite(self, realm_name: str, zonegroup_name: str, zone_name: str,
                              zonegroup_endpoints: str, zone_endpoints: str, username: str):
index d01187c4e247a9a13032ddf9ac46a1adee51d431..fb39bc8d519d474d9e46bf605326cd202fb2aef7 100644 (file)
@@ -415,3 +415,67 @@ class RgwUserControllerTestCase(ControllerTestCase):
         self.assertStatus(200)
         self.assertNotIn('keys', self.json_body())
         self.assertNotIn('swift_keys', self.json_body())
+
+    @patch('dashboard.services.rgw_client.mgr.send_rgwadmin_command')
+    @patch('dashboard.controllers.rgw.RgwRESTController.proxy')
+    def test_get_rate_limit(self, mock_proxy, send_rgwadmin_command):
+        mock_proxy.side_effect = [{
+            'count': 3,
+            'keys': ['test1', 'test2', 'test3'],
+            'truncated': False
+        }]
+        send_rgwadmin_command.return_value = (
+            0,
+            {
+                "user_ratelimit": {
+                    "max_read_ops": 100,
+                    "max_write_ops": 50
+                }
+            },
+            ""   # empty error msg
+        )
+
+        self._get('/test/api/rgw/user/testuser/ratelimit')
+        self.assertStatus(200)
+        self.assertInJsonBody('user_ratelimit')
+
+    @patch('dashboard.services.rgw_client.mgr.send_rgwadmin_command')
+    @patch('dashboard.controllers.rgw.RgwRESTController.proxy')
+    def test_get_global_rate_limit(self, mock_proxy, send_rgwadmin_command):
+        mock_proxy.side_effect = [{
+            'count': 3,
+            'keys': ['test1', 'test2', 'test3'],
+            'truncated': False
+        }]
+        mock_return_value = {
+            "bucket_ratelimit": {
+                "max_read_ops": 2024,
+                "max_write_ops": 0,
+                "max_read_bytes": 0,
+                "max_write_bytes": 0,
+                "enabled": True
+            },
+            "user_ratelimit": {
+                "max_read_ops": 1024,
+                "max_write_ops": 0,
+                "max_read_bytes": 0,
+                "max_write_bytes": 0,
+                "enabled": True
+            },
+            "anonymous_ratelimit": {
+                "max_read_ops": 0,
+                "max_write_ops": 0,
+                "max_read_bytes": 0,
+                "max_write_bytes": 0,
+                "enabled": True
+            }
+        }
+        send_rgwadmin_command.return_value = (
+            0,
+            mock_return_value,
+            ""   # empty error msg
+        )
+
+        self._get('/test/api/rgw/user/testuser/ratelimit')
+        self.assertStatus(200)
+        self.assertJsonBody(mock_return_value)