]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: add SMB share QoS rate limiting 67291/head
authorPedro Gonzalez Gomez <pegonzal@ibm.com>
Tue, 10 Feb 2026 16:18:36 +0000 (17:18 +0100)
committerPedro Gonzalez Gomez <pegonzal@ibm.com>
Mon, 2 Mar 2026 13:46:02 +0000 (14:46 +0100)
Fixes: https://tracker.ceph.com/issues/74856
Signed-off-by: Pedro Gonzalez Gomez <pegonzal@ibm.com>
13 files changed:
src/pybind/mgr/dashboard/controllers/smb.py
src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-form/smb-share-form.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb-share-list/smb-share-list.component.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/smb/smb.model.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/openapi.yaml
src/pybind/mgr/dashboard/tests/test_smb.py

index 88f471c76eb56507df647835e0a6ec2f6f25d4de..8926dedd5b849bbfb553beeda3098d81c82bb0ca 100644 (file)
@@ -3,14 +3,14 @@
 import json
 import logging
 from functools import wraps
-from typing import List
+from typing import List, Optional
 
 from smb.enums import Intent
 from smb.proto import Simplified
 from smb.resources import Cluster, JoinAuth, Share, UsersAndGroups
 
 from dashboard.controllers._docs import EndpointDoc
-from dashboard.controllers._permissions import CreatePermission, DeletePermission
+from dashboard.controllers._permissions import CreatePermission, DeletePermission, UpdatePermission
 from dashboard.exceptions import DashboardException
 
 from .. import mgr
@@ -61,7 +61,13 @@ SHARE_SCHEMA = {
         "provider": (str, "Provider of the CephFS share, e.g., 'samba-vfs'"),
         "subvolumegroup": (str, "Subvolume Group in CephFS file system"),
         "subvolume": (str, "Subvolume within the CephFS file system"),
-    }, "Configuration for the CephFS share")
+    }, "Configuration for the CephFS share"),
+    "read_iops_limit": (int, "QoS: max read IOPS (0=disabled)"),
+    "write_iops_limit": (int, "QoS: max write IOPS (0=disabled)"),
+    "read_bw_limit": (int, "QoS: max read bandwidth B/s (0=disabled)"),
+    "write_bw_limit": (int, "QoS: max write bandwidth B/s (0=disabled)"),
+    "read_delay_max": (int, "QoS: max read delay in seconds (0-300)"),
+    "write_delay_max": (int, "QoS: max write delay in seconds (0-300)"),
 }
 
 JOIN_AUTH_SCHEMA = {
@@ -272,7 +278,48 @@ class SMBShare(RESTController):
         return mgr.remote('smb', 'show', [f'{self._resource}.{cluster_id}.{share_id}'])
 
     @raise_on_failure
-    @DeletePermission
+    @UpdatePermission
+    @Endpoint(method='PUT', path='/qos')
+    @EndpointDoc(
+        "Update SMB share QoS rate limiting",
+        parameters={
+            'cluster_id': (str, 'Unique identifier for the cluster'),
+            'share_id': (str, 'Unique identifier for the share'),
+            'read_iops_limit': (int, 'Max read IOPS (0=disabled, 0-1000000)'),
+            'write_iops_limit': (int, 'Max write IOPS (0=disabled, 0-1000000)'),
+            'read_bw_limit': (int, 'Max read bandwidth B/s (0=disabled)'),
+            'write_bw_limit': (int, 'Max write bandwidth B/s (0=disabled)'),
+            'read_delay_max': (int, 'Max read delay in seconds (0-300)'),
+            'write_delay_max': (int, 'Max write delay in seconds (0-300)'),
+        })
+    def update_qos(
+        self,
+        cluster_id: str,
+        share_id: str,
+        read_iops_limit: Optional[int] = None,
+        write_iops_limit: Optional[int] = None,
+        read_bw_limit: Optional[int] = None,
+        write_bw_limit: Optional[int] = None,
+        read_delay_max: Optional[int] = None,
+        write_delay_max: Optional[int] = None,
+    ):
+        """
+        Update QoS rate limit parameters for an SMB share.
+        Omitted parameters are left unchanged. Use 0 to disable a limit.
+        """
+        return mgr.remote(
+            'smb',
+            'share_update_qos',
+            cluster_id,
+            share_id,
+            read_iops_limit,
+            write_iops_limit,
+            read_bw_limit,
+            write_bw_limit,
+            read_delay_max,
+            write_delay_max
+        ).to_simplified()
+
     @EndpointDoc("Remove an smb share",
                  parameters={
                      'cluster_id': (str, 'Unique identifier for the cluster'),
index 311609a371ec18c037d3ada49977d0230f4b8cdd..a9e3a6624696f24ae3e596c05965e7850317c7e7 100644 (file)
           <cd-help-text>If selected no clients are permitted to write to the share.</cd-help-text>
         </cds-checkbox>
       </div>
+
+      <!-- QoS rate limiting -->
+      <div class="form-header"
+           i18n>Rate limiting</div>
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <cds-number formControlName="read_iops_limit"
+                      cdValidate
+                      #readIopsRef="cdValidate"
+                      label="Read IOPS limit"
+                      i18n-label
+                      [min]="0"
+                      [max]="1000000"
+                      helperText="Max read operations per second (0 = disabled)"
+                      i18n-helperText
+                      [invalid]="readIopsRef.isInvalid"
+                      invalidText="Value must be between 0 and 1,000,000"
+                      i18n-invalidText>
+          </cds-number>
+        </div>
+        <div cdsCol>
+          <cds-number formControlName="write_iops_limit"
+                      cdValidate
+                      #writeIopsRef="cdValidate"
+                      label="Write IOPS limit"
+                      i18n-label
+                      [min]="0"
+                      [max]="1000000"
+                      helperText="Max write operations per second (0 = disabled)"
+                      i18n-helperText
+                      [invalid]="writeIopsRef.isInvalid"
+                      invalidText="Value must be between 0 and 1,000,000"
+                      i18n-invalidText>
+          </cds-number>
+        </div>
+      </div>
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <cd-number-with-unit
+            [form]="smbShareForm"
+            valueControlName="read_bw_limit"
+            unitControlName="read_bw_limit_unit"
+            label="Read bandwidth limit"
+            i18n-label
+            helperText="Max read bandwidth (0 = disabled)"
+            i18n-helperText
+            [units]="qosBwUnits"
+            [min]="0"
+            [max]="readBwMax"
+            invalidText="Value must be between 0 and {{ readBwMax | number }}"
+            i18n-invalidText
+          ></cd-number-with-unit>
+        </div>
+        <div cdsCol>
+          <cd-number-with-unit
+            [form]="smbShareForm"
+            valueControlName="write_bw_limit"
+            unitControlName="write_bw_limit_unit"
+            label="Write bandwidth limit"
+            i18n-label
+            helperText="Max write bandwidth (0 = disabled)"
+            i18n-helperText
+            [units]="qosBwUnits"
+            [min]="0"
+            [max]="writeBwMax"
+            invalidText="Value must be between 0 and {{ writeBwMax | number }}"
+            i18n-invalidText
+          ></cd-number-with-unit>
+        </div>
+      </div>
+      <div class="form-item form-item-append"
+           cdsRow>
+        <div cdsCol>
+          <cds-number formControlName="read_delay_max"
+                      cdValidate
+                      #readDelayRef="cdValidate"
+                      label="Read delay max (s)"
+                      i18n-label
+                      [min]="0"
+                      [max]="300"
+                      helperText="Max allowed delay for read operations in seconds"
+                      i18n-helperText
+                      [invalid]="readDelayRef.isInvalid"
+                      invalidText="Value must be between 0 and 300"
+                      i18n-invalidText>
+          </cds-number>
+        </div>
+        <div cdsCol>
+          <cds-number formControlName="write_delay_max"
+                      cdValidate
+                      #writeDelayRef="cdValidate"
+                      label="Write delay max (s)"
+                      i18n-label
+                      [min]="0"
+                      [max]="300"
+                      helperText="Max allowed delay for write operations in seconds"
+                      i18n-helperText
+                      [invalid]="writeDelayRef.isInvalid"
+                      invalidText="Value must be between 0 and 300"
+                      i18n-invalidText>
+          </cds-number>
+        </div>
+      </div>
+
       <cd-form-button-panel
         (submitActionEvent)="submitAction()"
         [form]="smbShareForm"
index 2c3ae8037b58378f593302675873a145488d8b77..d096f708f6287b93a4120dd23b6d4e049c128a20 100644 (file)
@@ -11,6 +11,7 @@ import {
   ComboBoxModule,
   GridModule,
   InputModule,
+  NumberModule,
   SelectModule
 } from 'carbon-components-angular';
 import { SmbService } from '~/app/shared/api/smb.service';
@@ -31,6 +32,7 @@ describe('SmbShareFormComponent', () => {
         ToastrModule.forRoot(),
         GridModule,
         InputModule,
+        NumberModule,
         SelectModule,
         ComboBoxModule,
         CheckboxModule
@@ -89,9 +91,53 @@ describe('SmbShareFormComponent', () => {
       prefixedPath: '/volumes/fs1/group1/subvol1',
       inputPath: '/',
       browseable: true,
-      readonly: false
+      readonly: false,
+      read_iops_limit: 0,
+      write_iops_limit: 0,
+      read_bw_limit: 0,
+      read_bw_limit_unit: 'MiB',
+      write_bw_limit: 0,
+      write_bw_limit_unit: 'MiB',
+      read_delay_max: 30,
+      write_delay_max: 30
     });
     component.submitAction();
     expect(component).toBeTruthy();
   });
+
+  describe('QoS', () => {
+    it('should have QoS form controls with default values', () => {
+      component.ngOnInit();
+      expect(component.smbShareForm.get('read_iops_limit')).toBeTruthy();
+      expect(component.smbShareForm.get('write_iops_limit')).toBeTruthy();
+      expect(component.smbShareForm.get('read_bw_limit')).toBeTruthy();
+      expect(component.smbShareForm.get('read_bw_limit_unit')).toBeTruthy();
+      expect(component.smbShareForm.get('write_bw_limit')).toBeTruthy();
+      expect(component.smbShareForm.get('write_bw_limit_unit')).toBeTruthy();
+      expect(component.smbShareForm.get('read_delay_max')).toBeTruthy();
+      expect(component.smbShareForm.get('write_delay_max')).toBeTruthy();
+      expect(component.smbShareForm.get('read_iops_limit').value).toBe(0);
+      expect(component.smbShareForm.get('write_delay_max').value).toBe(30);
+    });
+
+    it('should include QoS fields in buildRequest when set', () => {
+      component.clusterId = 'cluster1';
+      component.smbShareForm.patchValue({
+        share_id: 'share1',
+        name: 'My Share',
+        volume: 'fs1',
+        inputPath: '/',
+        read_iops_limit: 1000,
+        write_iops_limit: 500,
+        read_bw_limit: 100,
+        read_bw_limit_unit: 'MiB',
+        write_delay_max: 60
+      });
+      const request = component.buildRequest();
+      expect(request.share_resource.cephfs.qos.read_iops_limit).toBe(1000);
+      expect(request.share_resource.cephfs.qos.write_iops_limit).toBe(500);
+      expect(request.share_resource.cephfs.qos.read_bw_limit).toBe(100 * 1024 * 1024);
+      expect(request.share_resource.cephfs.qos.write_delay_max).toBe(60);
+    });
+  });
 });
index 52a41601a1fef13383a477e091f21b15f9fd004a..530bb654e5479e91c993199109ed87db9bb6430f 100644 (file)
@@ -18,11 +18,29 @@ import { CephfsSubvolume } from '~/app/shared/models/cephfs-subvolume.model';
 import { SmbService } from '~/app/shared/api/smb.service';
 import { NfsService } from '~/app/shared/api/nfs.service';
 import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service';
+import { FormatterService } from '~/app/shared/services/formatter.service';
+import { DimlessBinaryPipe } from '~/app/shared/pipes/dimless-binary.pipe';
 import { CephfsSubvolumeGroupService } from '~/app/shared/api/cephfs-subvolume-group.service';
 import { CephfsSubvolumeService } from '~/app/shared/api/cephfs-subvolume.service';
 import { CLUSTER_PATH } from '../smb-cluster-list/smb-cluster-list.component';
 import { SHARE_PATH } from '../smb-share-list/smb-share-list.component';
 
+const QOS_IOPS_MAX = 1_000_000;
+const QOS_BW_MAX_BYTES = 2 ** 40;
+const UNIT_TO_BYTES: Record<string, number> = {
+  KiB: 2 ** 10,
+  MiB: 2 ** 20,
+  GiB: 2 ** 30,
+  TiB: 2 ** 40
+};
+const QOS_DELAY_MAX = 300;
+const QOS_DELAY_DEFAULT = 30;
+const QOS_BW_UNITS = ['KiB', 'MiB', 'GiB', 'TiB'];
+
+function getBwMaxForUnit(unit: string): number {
+  return Math.floor(QOS_BW_MAX_BYTES / UNIT_TO_BYTES[unit]);
+}
+
 @Component({
   selector: 'cd-smb-share-form',
   templateUrl: './smb-share-form.component.html',
@@ -40,6 +58,9 @@ export class SmbShareFormComponent extends CdForm implements OnInit {
   isEdit = false;
   share_id: string;
   shareResponse: SMBShare;
+  qosBwUnits = QOS_BW_UNITS;
+  readBwMax = getBwMaxForUnit(QOS_BW_UNITS[1]);
+  writeBwMax = getBwMaxForUnit(QOS_BW_UNITS[1]);
 
   constructor(
     private formBuilder: CdFormBuilder,
@@ -50,7 +71,9 @@ export class SmbShareFormComponent extends CdForm implements OnInit {
     private subvolService: CephfsSubvolumeService,
     private taskWrapperService: TaskWrapperService,
     private router: Router,
-    private route: ActivatedRoute
+    private route: ActivatedRoute,
+    private formatter: FormatterService,
+    private dimlessBinaryPipe: DimlessBinaryPipe
   ) {
     super();
     this.resource = $localize`Share`;
@@ -69,26 +92,43 @@ export class SmbShareFormComponent extends CdForm implements OnInit {
     if (this.isEdit) {
       this.smbService.getShare(this.clusterId, this.share_id).subscribe((resp: SMBShare) => {
         this.shareResponse = resp;
-        this.smbShareForm.get('share_id').setValue(this.shareResponse.share_id);
+        const cephfs = this.shareResponse?.cephfs;
+        const qos = cephfs?.qos;
+
+        this.smbShareForm.patchValue({
+          share_id: this.shareResponse.share_id,
+          name: this.shareResponse.name,
+          volume: cephfs?.volume,
+          subvolume_group: cephfs?.subvolumegroup,
+          subvolume: cephfs?.subvolume,
+          inputPath: cephfs?.path,
+          readonly: this.shareResponse.readonly ?? false,
+          browseable: this.shareResponse.browseable ?? true,
+          read_iops_limit: qos?.read_iops_limit,
+          write_iops_limit: qos?.write_iops_limit,
+          read_delay_max: qos?.read_delay_max,
+          write_delay_max: qos?.write_delay_max
+        });
         this.smbShareForm.get('share_id').disable();
-        this.smbShareForm.get('name').setValue(this.shareResponse.name);
         this.smbShareForm.get('name').disable();
-        this.smbShareForm.get('volume').setValue(this.shareResponse?.cephfs?.volume);
-        this.smbShareForm
-          .get('subvolume_group')
-          .setValue(this.shareResponse?.cephfs?.subvolumegroup);
-        this.smbShareForm.get('subvolume').setValue(this.shareResponse?.cephfs?.subvolume);
-        this.smbShareForm.get('inputPath').setValue(this.shareResponse?.cephfs?.path);
-        if (this.shareResponse.readonly) {
-          this.smbShareForm.get('readonly').setValue(this.shareResponse.readonly);
-        }
-        if (this.shareResponse.browseable) {
-          this.smbShareForm.get('browseable').setValue(this.shareResponse.browseable);
-        }
+        this.setBwLimitFromBytes('read_bw_limit', qos?.read_bw_limit);
+        this.setBwLimitFromBytes('write_bw_limit', qos?.write_bw_limit);
 
-        this.getSubVolGrp(this.shareResponse?.cephfs?.volume);
+        this.getSubVolGrp(cephfs?.volume);
       });
     }
+    this.smbShareForm.get('read_bw_limit_unit').valueChanges.subscribe((unit: string) => {
+      this.readBwMax = getBwMaxForUnit(unit);
+      const ctrl = this.smbShareForm.get('read_bw_limit');
+      ctrl.setValidators([Validators.min(0), Validators.max(this.readBwMax)]);
+      ctrl.updateValueAndValidity();
+    });
+    this.smbShareForm.get('write_bw_limit_unit').valueChanges.subscribe((unit: string) => {
+      this.writeBwMax = getBwMaxForUnit(unit);
+      const ctrl = this.smbShareForm.get('write_bw_limit');
+      ctrl.setValidators([Validators.min(0), Validators.max(this.writeBwMax)]);
+      ctrl.updateValueAndValidity();
+    });
     this.smbShareForm.get('share_id')?.valueChanges.subscribe((value) => {
       const shareName = this.smbShareForm.get('name');
       if (shareName && !shareName.dirty) {
@@ -98,6 +138,12 @@ export class SmbShareFormComponent extends CdForm implements OnInit {
     this.loadingReady();
   }
 
+  private setBwLimitFromBytes(prefix: string, bytes: number) {
+    const parts = this.dimlessBinaryPipe.transform(bytes).split(' ');
+    this.smbShareForm.get(prefix).setValue(parts[0] || 0);
+    this.smbShareForm.get(prefix + '_unit').setValue(parts[1] || QOS_BW_UNITS[1]);
+  }
+
   createForm() {
     this.smbShareForm = this.formBuilder.group({
       share_id: new FormControl('', {
@@ -114,7 +160,21 @@ export class SmbShareFormComponent extends CdForm implements OnInit {
         validators: [Validators.required]
       }),
       browseable: new FormControl(true),
-      readonly: new FormControl(false)
+      readonly: new FormControl(false),
+      read_iops_limit: new FormControl(0, [Validators.min(0), Validators.max(QOS_IOPS_MAX)]),
+      write_iops_limit: new FormControl(0, [Validators.min(0), Validators.max(QOS_IOPS_MAX)]),
+      read_bw_limit: new FormControl(0, [Validators.min(0), Validators.max(this.readBwMax)]),
+      read_bw_limit_unit: new FormControl(QOS_BW_UNITS[1]),
+      write_bw_limit: new FormControl(0, [Validators.min(0), Validators.max(this.writeBwMax)]),
+      write_bw_limit_unit: new FormControl(QOS_BW_UNITS[1]),
+      read_delay_max: new FormControl(QOS_DELAY_DEFAULT, [
+        Validators.min(0),
+        Validators.max(QOS_DELAY_MAX)
+      ]),
+      write_delay_max: new FormControl(QOS_DELAY_DEFAULT, [
+        Validators.min(0),
+        Validators.max(QOS_DELAY_MAX)
+      ])
     });
   }
 
@@ -219,7 +279,23 @@ export class SmbShareFormComponent extends CdForm implements OnInit {
           path: correctedPath,
           subvolumegroup: rawFormValue.subvolume_group,
           subvolume: rawFormValue.subvolume,
-          provider: PROVIDER
+          provider: PROVIDER,
+          qos: {
+            read_iops_limit: rawFormValue.read_iops_limit,
+            write_iops_limit: rawFormValue.write_iops_limit,
+            read_bw_limit: this.formatter.toBytes(
+              String(this.smbShareForm.get('read_bw_limit').value) +
+                ' ' +
+                this.smbShareForm.get('read_bw_limit_unit').value
+            ),
+            write_bw_limit: this.formatter.toBytes(
+              String(this.smbShareForm.get('write_bw_limit').value) +
+                ' ' +
+                this.smbShareForm.get('write_bw_limit_unit').value
+            ),
+            read_delay_max: rawFormValue.read_delay_max,
+            write_delay_max: rawFormValue.write_delay_max
+          }
         },
         browseable: rawFormValue.browseable,
         readonly: rawFormValue.readonly
index 0363545f2291394e2b6c4a302867e4a910508c07..709d88db126db36f17ea8187bd8d5e4979b9dd2d 100644 (file)
     </div>
   </cd-table>
 </ng-container>
+
+<ng-template #iopsLimitTpl
+             let-value="data.value">
+  R: {{ value?.read_iops_limit ? (value.read_iops_limit | number) : '-' }}
+  / W: {{ value?.write_iops_limit ? (value.write_iops_limit | number) : '-' }}
+</ng-template>
+
+<ng-template #bwLimitTpl
+             let-value="data.value">
+  R: {{ value?.read_bw_limit ? (value.read_bw_limit | dimlessBinary) : '-' }}
+  / W: {{ value?.write_bw_limit ? (value.write_bw_limit | dimlessBinary) : '-' }}
+</ng-template>
+
+<ng-template #delayMaxTpl
+             let-value="data.value">
+  R: {{ value?.read_delay_max ? value.read_delay_max + 's' : '-' }}
+  / W: {{ value?.write_delay_max ? value.write_delay_max + 's' : '-' }}
+</ng-template>
index 0e910620cf3e88cdb3fafd0b9101484a2244d2b0..7a799b0ca4bdb138cb9720ce46c6b2f837cad06d 100644 (file)
@@ -1,4 +1,4 @@
-import { Component, Input, OnInit, ViewChild } from '@angular/core';
+import { Component, Input, OnInit, TemplateRef, ViewChild } from '@angular/core';
 import { Observable, BehaviorSubject, of } from 'rxjs';
 import { switchMap, catchError } from 'rxjs/operators';
 import { TableComponent } from '~/app/shared/datatable/table/table.component';
@@ -32,6 +32,12 @@ export class SmbShareListComponent implements OnInit {
   clusterId: string;
   @ViewChild('table', { static: true })
   table: TableComponent;
+  @ViewChild('iopsLimitTpl', { static: true })
+  iopsLimitTpl: TemplateRef<any>;
+  @ViewChild('bwLimitTpl', { static: true })
+  bwLimitTpl: TemplateRef<any>;
+  @ViewChild('delayMaxTpl', { static: true })
+  delayMaxTpl: TemplateRef<any>;
   columns: CdTableColumn[];
   permission: Permission;
   selection = new CdTableSelection();
@@ -89,6 +95,24 @@ export class SmbShareListComponent implements OnInit {
         name: $localize`Provider`,
         prop: 'cephfs.provider',
         flexGrow: 2
+      },
+      {
+        name: $localize`IOPS Limit`,
+        prop: 'cephfs.qos',
+        cellTemplate: this.iopsLimitTpl,
+        flexGrow: 2
+      },
+      {
+        name: $localize`Bandwidth Limit`,
+        prop: 'cephfs.qos',
+        cellTemplate: this.bwLimitTpl,
+        flexGrow: 2
+      },
+      {
+        name: $localize`Delay Max`,
+        prop: 'cephfs.qos',
+        cellTemplate: this.delayMaxTpl,
+        flexGrow: 2
       }
     ];
     this.tableActions = [
index 99244e9ec1e058788e3d616d956ed5178d90b524..25c09205efe0bf4a9063991ad0058e5a96be3f41 100644 (file)
@@ -28,6 +28,7 @@ interface SMBCephfs {
   subvolumegroup?: string;
   subvolume?: string;
   provider?: string;
+  qos?: SMBShareQoS;
 }
 
 interface SMBShareLoginControl {
@@ -77,6 +78,15 @@ export const PLACEMENT = {
   label: 'label'
 };
 
+export interface SMBShareQoS {
+  read_iops_limit?: number;
+  write_iops_limit?: number;
+  read_bw_limit?: number;
+  write_bw_limit?: number;
+  read_delay_max?: number;
+  write_delay_max?: number;
+}
+
 export interface SMBShare {
   resource_type: string;
   cluster_id: string;
@@ -90,20 +100,6 @@ export interface SMBShare {
   login_control?: SMBShareLoginControl;
 }
 
-interface SMBCephfs {
-  volume: string;
-  path: string;
-  subvolumegroup?: string;
-  subvolume?: string;
-  provider?: string;
-}
-
-interface SMBShareLoginControl {
-  name: string;
-  access: 'read' | 'read-write' | 'none' | 'admin';
-  category?: typeof USER | 'group';
-}
-
 export interface SMBJoinAuth {
   resource_type: string;
   auth_id: string;
index 45fe3a93e4aeef5d41d3365cbb3092e4e3b8df4d..61fc91830ea8b8a0f814f671fd60abe133170e95 100644 (file)
@@ -125,6 +125,7 @@ import CheckMarkOutline16 from '@carbon/icons/es/checkmark--outline/16';
 import { TearsheetStepComponent } from './tearsheet-step/tearsheet-step.component';
 import { PageHeaderComponent } from './page-header/page-header.component';
 import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.component';
+import { NumberWithUnitComponent } from './number-with-unit/number-with-unit.component';
 
 @NgModule({
   imports: [
@@ -223,7 +224,8 @@ import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.componen
     TearsheetComponent,
     TearsheetStepComponent,
     PageHeaderComponent,
-    SidebarLayoutComponent
+    SidebarLayoutComponent,
+    NumberWithUnitComponent
   ],
   providers: [provideCharts(withDefaultRegisterables())],
   exports: [
@@ -270,7 +272,8 @@ import { SidebarLayoutComponent } from './sidebar-layout/sidebar-layout.componen
     TearsheetComponent,
     TearsheetStepComponent,
     PageHeaderComponent,
-    SidebarLayoutComponent
+    SidebarLayoutComponent,
+    NumberWithUnitComponent
   ]
 })
 export class ComponentsModule {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.html
new file mode 100644 (file)
index 0000000..768f87e
--- /dev/null
@@ -0,0 +1,30 @@
+<div
+  cdsRow
+  [formGroup]="formGroup"
+>
+  <div cdsCol>
+    <cds-number
+      [label]="label"
+      [helperText]="helperText"
+      cdValidate
+      #valueRef="cdValidate"
+      [formControlName]="valueControlName"
+      [min]="min"
+      [max]="max"
+      [invalid]="valueRef.isInvalid"
+      [invalidText]="invalidText"
+    >
+    </cds-number>
+  </div>
+  <div cdsCol>
+    <cds-select
+      [formControlName]="unitControlName"
+      [label]="unitLabel"
+      [id]="unitControlName"
+    >
+      @for (unit of units; track unit) {
+      <option [value]="unit">{{ unit }}</option>
+      }
+    </cds-select>
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/number-with-unit/number-with-unit.component.ts
new file mode 100644 (file)
index 0000000..5bc42d1
--- /dev/null
@@ -0,0 +1,60 @@
+import { Component, Input, Optional, TemplateRef } from '@angular/core';
+import { ControlContainer, FormGroup } from '@angular/forms';
+
+@Component({
+  selector: 'cd-number-with-unit',
+  templateUrl: './number-with-unit.component.html',
+  styleUrls: ['./number-with-unit.component.scss'],
+  standalone: false
+})
+export class NumberWithUnitComponent {
+  @Input()
+  form: FormGroup;
+
+  @Input()
+  valueControlName: string;
+
+  /** Resolved form: parent form from ControlContainer when inside a form, otherwise @Input() form. */
+  get formGroup(): FormGroup {
+    const parent = this.controlContainer?.control as FormGroup | undefined;
+    return parent ?? this.form;
+  }
+
+  constructor(@Optional() private controlContainer: ControlContainer) {}
+
+  @Input()
+  unitControlName: string;
+
+  @Input()
+  label: string;
+
+  @Input()
+  helperText: string;
+
+  @Input()
+  units: string[] = ['KiB', 'MiB', 'GiB', 'TiB'];
+
+  @Input()
+  min: number = 0;
+
+  @Input()
+  max: number | undefined;
+
+  @Input()
+  unitLabel: string = $localize`Unit`;
+
+  @Input()
+  invalidText: string | TemplateRef<unknown> = $localize`The value should be greater or equal to 0`;
+
+  get valueControl() {
+    return this.formGroup?.get(this.valueControlName);
+  }
+
+  get unitControl() {
+    return this.formGroup?.get(this.unitControlName);
+  }
+
+  get invalidTextIsTemplate(): boolean {
+    return this.invalidText instanceof TemplateRef;
+  }
+}
index 5e1fc41fb3ae3b512953ea433be4714317b2f24b..7da76e63ed7ba1a41d92ec0eb7976c7e2fb699d6 100755 (executable)
@@ -23506,6 +23506,15 @@ paths:
                   name:
                     description: Name of the share
                     type: string
+                  read_bw_limit:
+                    description: 'QoS: max read bandwidth B/s (0=disabled)'
+                    type: integer
+                  read_delay_max:
+                    description: 'QoS: max read delay in seconds (0-300)'
+                    type: integer
+                  read_iops_limit:
+                    description: 'QoS: max read IOPS (0=disabled)'
+                    type: integer
                   readonly:
                     description: Indicates if the share is read-only
                     type: boolean
@@ -23515,6 +23524,15 @@ paths:
                   share_id:
                     description: Unique identifier for the share
                     type: string
+                  write_bw_limit:
+                    description: 'QoS: max write bandwidth B/s (0=disabled)'
+                    type: integer
+                  write_delay_max:
+                    description: 'QoS: max write delay in seconds (0-300)'
+                    type: integer
+                  write_iops_limit:
+                    description: 'QoS: max write IOPS (0=disabled)'
+                    type: integer
                 required: &id158
                 - resource_type
                 - cluster_id
@@ -23524,6 +23542,12 @@ paths:
                 - readonly
                 - browseable
                 - cephfs
+                - read_iops_limit
+                - write_iops_limit
+                - read_bw_limit
+                - write_bw_limit
+                - read_delay_max
+                - write_delay_max
                 type: object
             application/vnd.ceph.api.v1.0+json:
               schema:
@@ -23561,6 +23585,15 @@ paths:
                   name:
                     description: Name of the share
                     type: string
+                  read_bw_limit:
+                    description: 'QoS: max read bandwidth B/s (0=disabled)'
+                    type: integer
+                  read_delay_max:
+                    description: 'QoS: max read delay in seconds (0-300)'
+                    type: integer
+                  read_iops_limit:
+                    description: 'QoS: max read IOPS (0=disabled)'
+                    type: integer
                   readonly:
                     description: Indicates if the share is read-only
                     type: boolean
@@ -23570,6 +23603,15 @@ paths:
                   share_id:
                     description: Unique identifier for the share
                     type: string
+                  write_bw_limit:
+                    description: 'QoS: max write bandwidth B/s (0=disabled)'
+                    type: integer
+                  write_delay_max:
+                    description: 'QoS: max write delay in seconds (0-300)'
+                    type: integer
+                  write_iops_limit:
+                    description: 'QoS: max write IOPS (0=disabled)'
+                    type: integer
                 required: *id158
                 type: object
           description: OK
@@ -23655,6 +23697,15 @@ paths:
                             name:
                               description: Name of the share
                               type: string
+                            read_bw_limit:
+                              description: 'QoS: max read bandwidth B/s (0=disabled)'
+                              type: integer
+                            read_delay_max:
+                              description: 'QoS: max read delay in seconds (0-300)'
+                              type: integer
+                            read_iops_limit:
+                              description: 'QoS: max read IOPS (0=disabled)'
+                              type: integer
                             readonly:
                               description: Indicates if the share is read-only
                               type: boolean
@@ -23664,6 +23715,15 @@ paths:
                             share_id:
                               description: Unique identifier for the share
                               type: string
+                            write_bw_limit:
+                              description: 'QoS: max write bandwidth B/s (0=disabled)'
+                              type: integer
+                            write_delay_max:
+                              description: 'QoS: max write delay in seconds (0-300)'
+                              type: integer
+                            write_iops_limit:
+                              description: 'QoS: max write IOPS (0=disabled)'
+                              type: integer
                           required: &id160
                           - resource_type
                           - cluster_id
@@ -23673,6 +23733,12 @@ paths:
                           - readonly
                           - browseable
                           - cephfs
+                          - read_iops_limit
+                          - write_iops_limit
+                          - read_bw_limit
+                          - write_bw_limit
+                          - read_delay_max
+                          - write_delay_max
                           type: object
                         state:
                           description: The current state of the resource,                        e.g.,
@@ -23738,6 +23804,15 @@ paths:
                             name:
                               description: Name of the share
                               type: string
+                            read_bw_limit:
+                              description: 'QoS: max read bandwidth B/s (0=disabled)'
+                              type: integer
+                            read_delay_max:
+                              description: 'QoS: max read delay in seconds (0-300)'
+                              type: integer
+                            read_iops_limit:
+                              description: 'QoS: max read IOPS (0=disabled)'
+                              type: integer
                             readonly:
                               description: Indicates if the share is read-only
                               type: boolean
@@ -23747,6 +23822,15 @@ paths:
                             share_id:
                               description: Unique identifier for the share
                               type: string
+                            write_bw_limit:
+                              description: 'QoS: max write bandwidth B/s (0=disabled)'
+                              type: integer
+                            write_delay_max:
+                              description: 'QoS: max write delay in seconds (0-300)'
+                              type: integer
+                            write_iops_limit:
+                              description: 'QoS: max write IOPS (0=disabled)'
+                              type: integer
                           required: *id160
                           type: object
                         state:
@@ -23788,6 +23872,78 @@ paths:
       summary: Create smb share
       tags:
       - SMB
+  /api/smb/share/qos:
+    put:
+      description: "\n        Update QoS rate limit parameters for an SMB share.\n\
+        \        Omitted parameters are left unchanged. Use 0 to disable a limit.\n\
+        \        "
+      parameters: []
+      requestBody:
+        content:
+          application/json:
+            schema:
+              properties:
+                cluster_id:
+                  description: Unique identifier for the cluster
+                  type: string
+                read_bw_limit:
+                  description: Max read bandwidth B/s (0=disabled)
+                  type: integer
+                read_delay_max:
+                  description: Max read delay in seconds (0-300)
+                  type: integer
+                read_iops_limit:
+                  description: Max read IOPS (0=disabled, 0-1000000)
+                  type: integer
+                share_id:
+                  description: Unique identifier for the share
+                  type: string
+                write_bw_limit:
+                  description: Max write bandwidth B/s (0=disabled)
+                  type: integer
+                write_delay_max:
+                  description: Max write delay in seconds (0-300)
+                  type: integer
+                write_iops_limit:
+                  description: Max write IOPS (0=disabled, 0-1000000)
+                  type: integer
+              required:
+              - cluster_id
+              - share_id
+              type: object
+      responses:
+        '200':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                type: object
+          description: Resource updated.
+        '202':
+          content:
+            application/json:
+              schema:
+                type: object
+            application/vnd.ceph.api.v1.0+json:
+              schema:
+                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 SMB share QoS rate limiting
+      tags:
+      - SMB
   /api/smb/share/{cluster_id}/{share_id}:
     delete:
       description: "\n        Remove an smb share from a given cluster\n\n       \
@@ -23900,6 +24056,15 @@ paths:
                   name:
                     description: Name of the share
                     type: string
+                  read_bw_limit:
+                    description: 'QoS: max read bandwidth B/s (0=disabled)'
+                    type: integer
+                  read_delay_max:
+                    description: 'QoS: max read delay in seconds (0-300)'
+                    type: integer
+                  read_iops_limit:
+                    description: 'QoS: max read IOPS (0=disabled)'
+                    type: integer
                   readonly:
                     description: Indicates if the share is read-only
                     type: boolean
@@ -23909,6 +24074,15 @@ paths:
                   share_id:
                     description: Unique identifier for the share
                     type: string
+                  write_bw_limit:
+                    description: 'QoS: max write bandwidth B/s (0=disabled)'
+                    type: integer
+                  write_delay_max:
+                    description: 'QoS: max write delay in seconds (0-300)'
+                    type: integer
+                  write_iops_limit:
+                    description: 'QoS: max write IOPS (0=disabled)'
+                    type: integer
                 required: &id164
                 - resource_type
                 - cluster_id
@@ -23918,6 +24092,12 @@ paths:
                 - readonly
                 - browseable
                 - cephfs
+                - read_iops_limit
+                - write_iops_limit
+                - read_bw_limit
+                - write_bw_limit
+                - read_delay_max
+                - write_delay_max
                 type: object
             application/vnd.ceph.api.v1.0+json:
               schema:
@@ -23955,6 +24135,15 @@ paths:
                   name:
                     description: Name of the share
                     type: string
+                  read_bw_limit:
+                    description: 'QoS: max read bandwidth B/s (0=disabled)'
+                    type: integer
+                  read_delay_max:
+                    description: 'QoS: max read delay in seconds (0-300)'
+                    type: integer
+                  read_iops_limit:
+                    description: 'QoS: max read IOPS (0=disabled)'
+                    type: integer
                   readonly:
                     description: Indicates if the share is read-only
                     type: boolean
@@ -23964,6 +24153,15 @@ paths:
                   share_id:
                     description: Unique identifier for the share
                     type: string
+                  write_bw_limit:
+                    description: 'QoS: max write bandwidth B/s (0=disabled)'
+                    type: integer
+                  write_delay_max:
+                    description: 'QoS: max write delay in seconds (0-300)'
+                    type: integer
+                  write_iops_limit:
+                    description: 'QoS: max write IOPS (0=disabled)'
+                    type: integer
                 required: *id164
                 type: object
           description: OK
index 28a0db031a909d2f8fa00be5bb56fedb7ee88f6a..40b5749d1f965cd86d13b2f94f9270bef838a0a2 100644 (file)
@@ -220,6 +220,53 @@ class SMBShareTest(ControllerTestCase):
         self.assertStatus(204)
         mgr.remote.assert_called_once_with('smb', 'apply_resources', json.dumps(_res_simplified))
 
+    def test_get_share_with_qos(self):
+        import copy
+        share_with_qos = copy.deepcopy(self._shares['resources'][0])
+        share_with_qos['cephfs']['qos'] = {
+            'read_iops_limit': 1000,
+            'write_iops_limit': 500,
+            'read_bw_limit': 100000,
+            'write_bw_limit': 100000,
+            'read_delay_max': 30,
+            'write_delay_max': 60,
+        }
+        mgr.remote = Mock(return_value=share_with_qos)
+
+        self._get(f'{self._endpoint}/smbCluster1/share1')
+        self.assertStatus(200)
+        self.assertJsonBody(share_with_qos)
+        mgr.remote.assert_called_once_with(
+            'smb', 'show', ['ceph.smb.share.smbCluster1.share1']
+        )
+
+    def test_update_qos(self):
+        cluster_id = 'smbCluster1'
+        share_id = 'share1'
+
+        mock_result = Mock()
+        mock_result.to_simplified.return_value = {
+            'resource_type': 'ceph.smb.share',
+            'cluster_id': cluster_id,
+            'share_id': share_id,
+        }
+        mgr.remote = Mock(return_value=mock_result)
+
+        qos_body = {
+            'cluster_id': cluster_id,
+            'share_id': share_id,
+            'read_iops_limit': 1000,
+            'write_iops_limit': 500,
+        }
+        self._put(f'{self._endpoint}/qos', qos_body)
+        self.assertStatus(200)
+
+        mgr.remote.assert_called_once_with(
+            'smb', 'share_update_qos',
+            cluster_id, share_id,
+            1000, 500, None, None, None, None
+        )
+
 
 class SMBJoinAuthTest(ControllerTestCase):
     _endpoint = '/api/smb/joinauth'