]> git-server-git.apps.pok.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Edit should support setting one or more rgw service configuration... 70278/head
authorNaman Munet <naman.munet@ibm.com>
Fri, 17 Jul 2026 09:52:49 +0000 (15:22 +0530)
committerNaman Munet <naman.munet@ibm.com>
Wed, 22 Jul 2026 08:04:29 +0000 (13:34 +0530)
fixes: https://tracker.ceph.com/issues/78332

Before
===
The configuration form had a TypeScript compilation error and could not properly support editing client-specific configurations. The form lacked the ability to:

- Configure different values for specific client entities (e.g., client.rgw.*, client.admin, client.radosgw-gateway)
- No way to Add/remove multiple client entity entries dynamically
- No way to provide autocomplete suggestions for existing client entities

After
===
Users can now edit configuration options individually for any client entity, with a clean UI that supports:

- Generic client entity support: Configure any client.* entity (RGW daemons, admin users, CephFS clients, etc.)
- Autocomplete with custom values: Select from existing client entities or enter custom ones (e.g., client.rgw.my-daemon, client.admin)
- Dynamic entry management: Add/remove multiple client configuration entries

Signed-off-by: Naman Munet <naman.munet@ibm.com>
src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts

index b71719c4396c12b68f5aedbae9984e41164c157b..34986115f680bc38cef69eb0d0586ac910e115e4 100644 (file)
@@ -43,8 +43,7 @@ describe('Configuration page', () => {
         ['mon', '2'],
         ['mgr', '3'],
         ['osd', '4'],
-        ['mds', '5'],
-        ['client', '6']
+        ['mds', '5']
       );
     });
 
index 6c2b44965285190b46f4f491951f1fdb62e14bf4..b388b24ded758d9b65522970bdd5768fc104f370 100644 (file)
@@ -19,7 +19,7 @@ export class ConfigurationPageHelper extends PageHelper {
    */
   configClear(name: string) {
     this.navigateTo();
-    const valList = ['global', 'mon', 'mgr', 'osd', 'mds', 'client']; // Editable values
+    const valList = ['global', 'mon', 'mgr', 'osd', 'mds']; // Editable values (client moved to separate section)
     this.getFirstTableCell(name).click();
     cy.contains('button', 'Edit').click();
     this.waitForEditForm(name);
index 28089e5c5a3bc768cd450bc1d6581a651daabeda..705e91aa3d14ae3c7ff2cfa74ae15e1d1b5ca9c8 100644 (file)
           </div>
 
           @for (section of availSections; track section) {
-            @if (type === 'bool') {
+            <div
+              cdsRow
+              class="form-item"
+            >
+              <ng-container
+                *ngTemplateOutlet="
+                  configFieldTemplate;
+                  context: {
+                    controlName: section,
+                    label: section,
+                    id: section,
+                    control: configForm.get('values')?.get(section)
+                  }
+                "
+              ></ng-container>
+            </div>
+          }
+        </div>
+
+        <!-- Client-specific configuration entries -->
+        <div
+          cdsRow
+          class="form-heading cds-mt-4"
+        >
+          <h3 i18n>Client Configuration</h3>
+        </div>
+
+        <div
+          cdsRow
+          class="form-item"
+        >
+          <p class="cds--type-body-01">
+            <span i18n
+              >Configure this option for specific client entities. Select from the dropdown or enter
+              custom values like
+            </span>
+            <code>client.rgw.my-daemon</code>
+            <span i18n> or </span>
+            <code>client.admin</code>
+            <span i18n>.</span>
+          </p>
+        </div>
+
+        <div formArrayName="clientEntries">
+          @for (entry of clientEntries.controls; track entry; let i = $index) {
+            <div
+              [formGroupName]="i"
+              cdsRow
+              class="cds-mt-6"
+            >
               <div
-                cdsRow
-                class="form-item"
+                cdsCol
+                [columnNumbers]="{ lg: 4 }"
               >
-                <cds-select
-                  [formControlName]="section"
-                  [label]="section"
-                  [id]="section"
-                  [labelInputID]="section"
-                  cdValidate
-                  #sectionValidate="cdValidate"
-                  [invalid]="sectionValidate.isInvalid"
-                  [invalidText]="sectionErrors"
+                <cds-text-label
+                  [labelInputID]="'client-entity-' + i"
+                  [invalid]="entityValidate.isInvalid"
+                  [invalidText]="entityErrors"
                 >
-                  <option
-                    [ngValue]="null"
-                    i18n
-                  >
-                    -- Default --
-                  </option>
-                  <option
-                    [ngValue]="true"
-                    i18n
-                  >
-                    true
-                  </option>
-                  <option
-                    [ngValue]="false"
-                    i18n
-                  >
-                    false
-                  </option>
-                </cds-select>
-                <ng-template #sectionErrors>
+                  <span i18n>Client Entity</span>
+                  <input
+                    cdsText
+                    cdValidate
+                    #entityValidate="cdValidate"
+                    type="text"
+                    [id]="'client-entity-' + i"
+                    formControlName="clientEntity"
+                    placeholder="e.g., client.rgw.my-daemon, client.admin"
+                    i18n-placeholder
+                    [invalid]="entityValidate.isInvalid"
+                    list="client-entity-datalist"
+                  />
+                </cds-text-label>
+                <datalist id="client-entity-datalist">
+                  @for (option of clientEntityOptions; track option.content) {
+                    <option [value]="option.content">{{ option.content }}</option>
+                  }
+                </datalist>
+                <ng-template #entityErrors>
                   <ng-container
                     *ngTemplateOutlet="
                       validationErrors;
-                      context: { control: configForm.get(section) }
+                      context: { control: entry.get('clientEntity') }
                     "
                   ></ng-container>
                 </ng-template>
               </div>
-            }
 
-            @if (type !== 'bool' && inputType === 'number') {
               <div
-                cdsRow
-                class="form-item"
+                cdsCol
+                [columnNumbers]="{ lg: 4 }"
               >
-                <cds-number
-                  [formControlName]="section"
-                  [label]="section"
-                  [id]="section"
-                  [step]="getStep(type, configForm.getValue(section))"
-                  [min]="minValue"
-                  [max]="maxValue"
-                  cdValidate
-                  #sectionValidate="cdValidate"
-                  [invalid]="sectionValidate.isInvalid"
-                  [invalidText]="sectionErrors"
-                >
-                </cds-number>
-                <ng-template #sectionErrors>
-                  <ng-container
-                    *ngTemplateOutlet="
-                      validationErrors;
-                      context: { control: configForm.get(section) }
-                    "
-                  ></ng-container>
-                </ng-template>
+                <ng-container
+                  *ngTemplateOutlet="
+                    configFieldTemplate;
+                    context: {
+                      controlName: 'value',
+                      label: 'Value',
+                      id: 'client-value-' + i,
+                      control: entry.get('value')
+                    }
+                  "
+                ></ng-container>
               </div>
-            }
 
-            @if (type !== 'bool' && inputType !== 'number') {
               <div
-                cdsRow
-                class="form-item"
+                cdsCol
+                class="item-action-btn"
               >
-                <cds-text-label
-                  [labelInputID]="section"
-                  [invalid]="sectionValidate.isInvalid"
-                  [invalidText]="sectionErrors"
+                <cds-icon-button
+                  kind="danger"
+                  size="sm"
+                  description="Remove client entry"
+                  i18n-description
+                  (click)="removeClientEntry(i)"
                 >
-                  {{ section }}
-                  <input
-                    cdsText
-                    cdValidate
-                    #sectionValidate="cdValidate"
-                    [type]="inputType"
-                    [id]="section"
-                    [formControlName]="section"
-                    [invalid]="sectionValidate.isInvalid"
-                  />
-                </cds-text-label>
-                <ng-template #sectionErrors>
-                  <ng-container
-                    *ngTemplateOutlet="
-                      validationErrors;
-                      context: { control: configForm.get(section) }
-                    "
-                  ></ng-container>
-                </ng-template>
+                  <svg
+                    cdsIcon="trash-can"
+                    size="32"
+                    class="cds--btn__icon"
+                  ></svg>
+                </cds-icon-button>
               </div>
-            }
+            </div>
           }
+
+          <div
+            cdsRow
+            class="cds-mt-6"
+          >
+            <button
+              cdsButton="tertiary"
+              [size]="'md'"
+              type="button"
+              (click)="addClientEntry()"
+              i18n
+            >
+              <svg
+                cdsIcon="add"
+                size="16"
+                class="cds--btn__icon"
+              ></svg>
+              Add Client Entry
+            </button>
+          </div>
         </div>
 
         <div cdsRow>
           <cd-form-button-panel
             (submitActionEvent)="forceUpdate ? openCriticalConfirmModal() : submit()"
             [form]="configForm"
+            [disabled]="configForm.invalid"
             [submitText]="actionLabels.UPDATE"
             wrappingClass="text-right form-button"
           ></cd-form-button-panel>
   </form>
 </ng-container>
 
+<!-- Reusable template for configuration fields -->
+<ng-template
+  #configFieldTemplate
+  let-controlName="controlName"
+  let-label="label"
+  let-id="id"
+  let-control="control"
+>
+  @if (control) {
+    @if (type === 'bool') {
+      <cds-select
+        [formControl]="control"
+        [label]="label"
+        i18n-label
+        [id]="id"
+        cdValidate
+        #fieldValidate="cdValidate"
+        [invalid]="fieldValidate.isInvalid"
+        [invalidText]="fieldErrors"
+      >
+        <option
+          [ngValue]="null"
+          i18n
+        >
+          -- Default --
+        </option>
+        <option
+          [ngValue]="true"
+          i18n
+        >
+          true
+        </option>
+        <option
+          [ngValue]="false"
+          i18n
+        >
+          false
+        </option>
+      </cds-select>
+      <ng-template #fieldErrors>
+        <ng-container
+          *ngTemplateOutlet="validationErrors; context: { control: control }"
+        ></ng-container>
+      </ng-template>
+    }
+
+    @if (type !== 'bool' && inputType === 'number') {
+      <cds-number
+        [formControl]="control"
+        [label]="label"
+        i18n-label
+        [id]="id"
+        [min]="minValue"
+        [max]="maxValue"
+        [step]="getStep(type, control?.value) ?? 1"
+        cdValidate
+        #fieldValidate="cdValidate"
+        [invalid]="fieldValidate.isInvalid"
+        [invalidText]="fieldErrors"
+      >
+      </cds-number>
+      <ng-template #fieldErrors>
+        <ng-container
+          *ngTemplateOutlet="validationErrors; context: { control: control }"
+        ></ng-container>
+      </ng-template>
+    }
+
+    @if (type !== 'bool' && inputType !== 'number') {
+      <cds-text-label
+        [labelInputID]="id"
+        [invalid]="fieldValidate.isInvalid"
+        [invalidText]="fieldErrors"
+      >
+        <span i18n>{{ label }}</span>
+        <input
+          cdsText
+          cdValidate
+          #fieldValidate="cdValidate"
+          [type]="inputType"
+          [id]="id"
+          [formControl]="control"
+          [invalid]="fieldValidate.isInvalid"
+        />
+      </cds-text-label>
+      <ng-template #fieldErrors>
+        <ng-container
+          *ngTemplateOutlet="validationErrors; context: { control: control }"
+        ></ng-container>
+      </ng-template>
+    }
+  }
+</ng-template>
+
 <ng-template
   #validationErrors
   let-control="control"
 >
   @if (control?.invalid && (control.dirty || control.touched)) {
+    @if (control.hasError('required')) {
+      <div
+        class="invalid-feedback"
+        i18n
+      >
+        This field is required.
+      </div>
+    }
+    @if (control.hasError('clientPrefix')) {
+      <div
+        class="invalid-feedback"
+        i18n
+      >
+        Client entity must start with 'client.' <br />
+        (e.g., client.admin, client.rgw.my-daemon)
+      </div>
+    }
+    @if (control.hasError('clientEntityDuplicate')) {
+      <div
+        class="invalid-feedback"
+        i18n
+      >
+        This client entity is already configured. <br />
+        Each client entity can only be configured once.
+      </div>
+    }
     @if (control.hasError('min')) {
       <span
         class="invalid-feedback"
index fc5b96c5047ceba11deadcd7d38e3e5a372af0b8..5eba7bf615b8bd390412f956b6b1a54f4c542c3d 100644 (file)
@@ -1,6 +1,6 @@
 import { HttpClientTestingModule } from '@angular/common/http/testing';
 import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ReactiveFormsModule } from '@angular/forms';
+import { ReactiveFormsModule, UntypedFormGroup } from '@angular/forms';
 import { RouterTestingModule } from '@angular/router/testing';
 
 import { ConfigFormModel } from '~/app/shared/components/config-option/config-option.model';
@@ -89,4 +89,360 @@ describe('ConfigurationFormComponent', () => {
       expect(component.maxValue).toBe(5.2);
     });
   });
+
+  describe('Client Configuration', () => {
+    beforeEach(() => {
+      component.response = new ConfigFormModel();
+      component.response.name = 'test_config';
+      component.response.type = 'str';
+    });
+
+    describe('initializeClientEntries', () => {
+      it('should initialize with no entries when no client values exist', () => {
+        component.response.value = [];
+        component.initializeClientEntries();
+
+        expect(component.clientEntries.length).toBe(0);
+      });
+
+      it('should load existing client and client.* configurations', () => {
+        component.response.type = 'bool';
+        component.response.value = [
+          { section: 'client', value: 'false' },
+          { section: 'client.rgw.my-daemon', value: 'true' },
+          { section: 'client.admin', value: '100' }
+        ];
+        component.initializeClientEntries();
+
+        expect(component.clientEntries.length).toBe(3);
+
+        const firstEntry = component.clientEntries.at(0) as UntypedFormGroup;
+        expect(firstEntry.get('clientEntity')?.value).toBe('client');
+        expect(firstEntry.get('value')?.value).toBe(false);
+
+        const secondEntry = component.clientEntries.at(1) as UntypedFormGroup;
+        expect(secondEntry.get('clientEntity')?.value).toBe('client.rgw.my-daemon');
+        expect(secondEntry.get('value')?.value).toBe(true);
+
+        const thirdEntry = component.clientEntries.at(2) as UntypedFormGroup;
+        expect(thirdEntry.get('clientEntity')?.value).toBe('client.admin');
+        expect(thirdEntry.get('value')?.value).toBe('100');
+      });
+
+      it('should filter duplicate client sections', () => {
+        component.response.value = [
+          { section: 'client.rgw.daemon1', value: 'true' },
+          { section: 'client.rgw.daemon1', value: 'false' }
+        ];
+        component.initializeClientEntries();
+
+        expect(component.clientEntries.length).toBe(1);
+      });
+    });
+
+    describe('addClientEntry', () => {
+      it('should add a new empty client entry', () => {
+        component.response.value = [];
+        component.initializeClientEntries();
+        expect(component.clientEntries.length).toBe(0);
+        component.addClientEntry();
+        expect(component.clientEntries.length).toBe(1);
+        const newEntry = component.clientEntries.at(0) as UntypedFormGroup;
+        expect(newEntry.get('clientEntity')?.value).toBe('');
+        expect(newEntry.get('value')?.value).toBeNull();
+      });
+    });
+
+    describe('removeClientEntry', () => {
+      it('should remove a client entry at specified index', () => {
+        component.response.value = [
+          { section: 'client.rgw.daemon1', value: 'true' },
+          { section: 'client.admin', value: '100' }
+        ];
+        component.initializeClientEntries();
+        expect(component.clientEntries.length).toBe(2);
+
+        component.removeClientEntry(0);
+
+        expect(component.clientEntries.length).toBe(1);
+        const remainingEntry = component.clientEntries.at(0) as UntypedFormGroup;
+        expect(remainingEntry.get('clientEntity')?.value).toBe('client.admin');
+      });
+    });
+
+    describe('clientPrefixValidator', () => {
+      beforeEach(() => {
+        component.response.value = [];
+        component.createForm();
+        component.initializeClientEntries();
+        component.addClientEntry();
+      });
+
+      it('should accept values starting with "client."', () => {
+        const entry = component.clientEntries.at(0) as UntypedFormGroup;
+        const control = entry.get('clientEntity');
+
+        control?.setValue('client.rgw.my-daemon');
+        expect(control?.hasError('clientPrefix')).toBe(false);
+
+        control?.setValue('client.admin');
+        expect(control?.hasError('clientPrefix')).toBe(false);
+      });
+
+      it('should reject values not starting with "client"', () => {
+        const entry = component.clientEntries.at(0) as UntypedFormGroup;
+        const control = entry.get('clientEntity');
+
+        control?.setValue('rgw.my-daemon');
+        expect(control?.hasError('clientPrefix')).toBe(true);
+
+        control?.setValue('admin');
+        expect(control?.hasError('clientPrefix')).toBe(true);
+      });
+
+      it('should allow empty values (handled by required validator)', () => {
+        const entry = component.clientEntries.at(0) as UntypedFormGroup;
+        const control = entry.get('clientEntity');
+
+        control?.setValue('');
+        expect(control?.hasError('clientPrefix')).toBe(false);
+        expect(control?.hasError('required')).toBe(true);
+      });
+    });
+
+    describe('clientEntityUniquenessValidator', () => {
+      beforeEach(() => {
+        component.response.value = [];
+        component.createForm();
+        component.initializeClientEntries();
+      });
+
+      it('should reject duplicate client entities', () => {
+        // Add two entries with the same client entity
+        component.addClientEntry();
+        component.addClientEntry();
+
+        const firstEntry = component.clientEntries.at(0) as UntypedFormGroup;
+        const secondEntry = component.clientEntries.at(1) as UntypedFormGroup;
+
+        firstEntry.get('clientEntity')?.setValue('client.rgw.daemon1');
+        secondEntry.get('clientEntity')?.setValue('client.rgw.daemon1');
+
+        // Both should have the duplicate error
+        expect(firstEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(true);
+        expect(secondEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(true);
+      });
+
+      it('should allow unique client entities', () => {
+        component.addClientEntry();
+        component.addClientEntry();
+
+        const firstEntry = component.clientEntries.at(0) as UntypedFormGroup;
+        const secondEntry = component.clientEntries.at(1) as UntypedFormGroup;
+
+        firstEntry.get('clientEntity')?.setValue('client.rgw.daemon1');
+        secondEntry.get('clientEntity')?.setValue('client.admin');
+
+        // Neither should have the duplicate error
+        expect(firstEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(false);
+        expect(secondEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(false);
+      });
+
+      it('should revalidate sibling entries when an entity changes', () => {
+        component.addClientEntry();
+        component.addClientEntry();
+
+        const firstEntry = component.clientEntries.at(0) as UntypedFormGroup;
+        const secondEntry = component.clientEntries.at(1) as UntypedFormGroup;
+
+        // Set both to the same value
+        firstEntry.get('clientEntity')?.setValue('client.rgw.daemon1');
+        secondEntry.get('clientEntity')?.setValue('client.rgw.daemon1');
+
+        // Both should have errors
+        expect(firstEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(true);
+        expect(secondEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(true);
+
+        // Change the second one to a different value
+        secondEntry.get('clientEntity')?.setValue('client.admin');
+
+        // Now neither should have errors
+        expect(firstEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(false);
+        expect(secondEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(false);
+      });
+
+      it('should revalidate remaining entries after removal', () => {
+        component.addClientEntry();
+        component.addClientEntry();
+        component.addClientEntry();
+
+        const firstEntry = component.clientEntries.at(0) as UntypedFormGroup;
+        const secondEntry = component.clientEntries.at(1) as UntypedFormGroup;
+        const thirdEntry = component.clientEntries.at(2) as UntypedFormGroup;
+
+        // Set first and second to the same value
+        firstEntry.get('clientEntity')?.setValue('client.rgw.daemon1');
+        secondEntry.get('clientEntity')?.setValue('client.rgw.daemon1');
+        thirdEntry.get('clientEntity')?.setValue('client.admin');
+
+        // First and second should have errors
+        expect(firstEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(true);
+        expect(secondEntry.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(true);
+
+        // Remove the second entry
+        component.removeClientEntry(1);
+
+        // Now the first entry should not have an error (no more duplicates)
+        const remainingFirst = component.clientEntries.at(0) as UntypedFormGroup;
+        expect(remainingFirst.get('clientEntity')?.hasError('clientEntityDuplicate')).toBe(false);
+      });
+    });
+
+    describe('prepareClientEntityOptions', () => {
+      it('should filter and map client entities starting with "client."', () => {
+        component.clientEntities = [
+          { entity: 'client.rgw.daemon1', caps: {}, key: 'key1' },
+          { entity: 'client.admin', caps: {}, key: 'key2' },
+          { entity: 'osd.0', caps: {}, key: 'key3' },
+          { entity: 'mgr.x', caps: {}, key: 'key4' }
+        ];
+
+        component.prepareClientEntityOptions();
+
+        expect(component.clientEntityOptions.length).toBe(2);
+        expect(component.clientEntityOptions[0].content).toBe('client.rgw.daemon1');
+        expect(component.clientEntityOptions[1].content).toBe('client.admin');
+      });
+
+      it('should handle empty client entities list', () => {
+        component.clientEntities = [];
+
+        component.prepareClientEntityOptions();
+
+        expect(component.clientEntityOptions.length).toBe(0);
+      });
+    });
+
+    describe('parseConfigValue', () => {
+      it('should parse boolean strings only for bool type', () => {
+        component.response.type = 'bool';
+        expect(component['parseConfigValue']('true')).toBe(true);
+        expect(component['parseConfigValue']('false')).toBe(false);
+      });
+
+      it('should not parse boolean strings for non-bool types', () => {
+        component.response.type = 'str';
+        expect(component['parseConfigValue']('true')).toBe('true');
+        expect(component['parseConfigValue']('false')).toBe('false');
+      });
+
+      it('should parse numeric strings only for numeric types', () => {
+        component.response.type = 'int';
+        expect(component['parseConfigValue']('123')).toBe(123);
+        expect(component['parseConfigValue']('  89  ')).toBe(89);
+
+        component.response.type = 'float';
+        expect(component['parseConfigValue']('45.67')).toBe(45.67);
+
+        component.response.type = 'uint';
+        expect(component['parseConfigValue']('100')).toBe(100);
+      });
+
+      it('should preserve string values with leading zeros for str type', () => {
+        component.response.type = 'str';
+        expect(component['parseConfigValue']('00123')).toBe('00123');
+        expect(component['parseConfigValue']('007')).toBe('007');
+      });
+
+      it('should return string for non-numeric types', () => {
+        component.response.type = 'str';
+        expect(component['parseConfigValue']('some_string')).toBe('some_string');
+        expect(component['parseConfigValue']('192.168.1.1')).toBe('192.168.1.1');
+        expect(component['parseConfigValue']('123')).toBe('123');
+      });
+
+      it('should handle non-string inputs', () => {
+        component.response.type = 'str';
+        expect(component['parseConfigValue'](123 as any)).toBe(123);
+        expect(component['parseConfigValue'](true as any)).toBe(true);
+      });
+    });
+
+    describe('setResponse', () => {
+      beforeEach(() => {
+        component.createForm();
+      });
+
+      it('should add daemon-specific sections dynamically', () => {
+        const response = new ConfigFormModel();
+        response.name = 'test_config';
+        response.type = 'str';
+        response.value = [
+          { section: 'mgr.ceph-node-00.vfbbqn', value: 'mgr_value' },
+          { section: 'osd.0', value: 'osd_value' }
+        ];
+
+        component.setResponse(response);
+
+        expect(component.availSections).toContain('mgr.ceph-node-00.vfbbqn');
+        expect(component.availSections).toContain('osd.0');
+      });
+    });
+
+    describe('createRequest', () => {
+      beforeEach(() => {
+        component.response = new ConfigFormModel();
+        component.response.name = 'test_config';
+        component.response.type = 'str';
+        component.response.value = [];
+        component.createForm();
+      });
+
+      it('should include client entity configurations in request', () => {
+        component.response.value = [];
+        component.initializeClientEntries();
+        component.addClientEntry();
+
+        const entry = component.clientEntries.at(0) as UntypedFormGroup;
+        entry.get('clientEntity')?.setValue('client.rgw.my-daemon');
+        entry.get('value')?.setValue('true');
+
+        const request = component.createRequest();
+
+        expect(request).toBeTruthy();
+        expect(request?.value).toContainEqual({ section: 'client.rgw.my-daemon', value: 'true' });
+      });
+
+      it('should remove deleted client configurations', () => {
+        component.response.type = 'int';
+        component.response.value = [
+          { section: 'client.rgw.daemon1', value: 'true' },
+          { section: 'client.admin', value: '100' }
+        ];
+        component.initializeClientEntries();
+
+        component.removeClientEntry(0);
+
+        const request = component.createRequest();
+
+        expect(request).toBeTruthy();
+        expect(request?.value).toContainEqual({ section: 'client.rgw.daemon1', value: '' });
+        expect(request?.value).toContainEqual({ section: 'client.admin', value: 100 });
+      });
+
+      it('should not include empty client entries', () => {
+        component.response.value = [];
+        component.initializeClientEntries();
+        component.addClientEntry();
+
+        const entry = component.clientEntries.at(0) as UntypedFormGroup;
+        entry.get('clientEntity')?.setValue('');
+        entry.get('value')?.setValue('');
+
+        const request = component.createRequest();
+
+        expect(request).toBeNull();
+      });
+    });
+  });
 });
index c98f93dc93b407045a2fc243ff4b009e22b91e87..9b71debfc40a1d3d406c2b7be7187ddf5c46d07f 100644 (file)
@@ -1,8 +1,19 @@
-import { Component, OnInit } from '@angular/core';
-import { UntypedFormControl, UntypedFormGroup, ValidatorFn } from '@angular/forms';
-import { ActivatedRoute, Router } from '@angular/router';
+import { ChangeDetectorRef, Component, OnInit } from '@angular/core';
+import {
+  UntypedFormControl,
+  UntypedFormGroup,
+  UntypedFormArray,
+  ValidatorFn,
+  FormArray,
+  Validators,
+  AbstractControl,
+  ValidationErrors
+} from '@angular/forms';
+import { ActivatedRoute, Router, Params } from '@angular/router';
 
 import _ from 'lodash';
+import { switchMap, catchError } from 'rxjs/operators';
+import { of } from 'rxjs';
 
 import { ConfigurationService } from '~/app/shared/api/configuration.service';
 import { ConfigFormModel } from '~/app/shared/components/config-option/config-option.model';
@@ -15,8 +26,18 @@ import { NotificationService } from '~/app/shared/services/notification.service'
 import { ConfigFormCreateRequestModel } from './configuration-form-create-request.model';
 import { DeleteConfirmationModalComponent } from '~/app/shared/components/delete-confirmation-modal/delete-confirmation-modal.component';
 import { ModalCdsService } from '~/app/shared/services/modal-cds.service';
-
+import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
 const RGW = 'rgw';
+interface ClientEntityItem {
+  content: string;
+  name: string;
+}
+
+interface CephUser {
+  entity: string;
+  caps: { [key: string]: string };
+  key: string;
+}
 
 @Component({
   selector: 'cd-configuration-form',
@@ -25,16 +46,19 @@ const RGW = 'rgw';
   standalone: false
 })
 export class ConfigurationFormComponent extends CdForm implements OnInit {
-  configForm: CdFormGroup;
-  response: ConfigFormModel;
-  type: string;
-  inputType: string;
-  humanReadableType: string;
-  minValue: number;
-  maxValue: number;
-  patternHelpText: string;
-  availSections = ['global', 'mon', 'mgr', 'osd', 'mds', 'client'];
-  forceUpdate: boolean;
+  configForm!: CdFormGroup;
+  response!: ConfigFormModel;
+  type!: string;
+  inputType!: string;
+  humanReadableType!: string;
+  minValue!: number;
+  maxValue!: number;
+  patternHelpText!: string;
+  private readonly allSections = ['global', 'mon', 'mgr', 'osd', 'mds'];
+  availSections: string[] = [];
+  forceUpdate!: boolean;
+  clientEntities: CephUser[] = [];
+  clientEntityOptions: ClientEntityItem[] = [];
 
   constructor(
     public actionLabels: ActionLabelsI18n,
@@ -42,12 +66,18 @@ export class ConfigurationFormComponent extends CdForm implements OnInit {
     private router: Router,
     private configService: ConfigurationService,
     private notificationService: NotificationService,
-    private modalService: ModalCdsService
+    private modalService: ModalCdsService,
+    private dataGatewayService: DataGatewayService,
+    private cd: ChangeDetectorRef
   ) {
     super();
     this.createForm();
   }
 
+  get clientEntries(): FormArray {
+    return this.configForm.get('clientEntries') as FormArray;
+  }
+
   createForm() {
     const formControls = {
       name: new UntypedFormControl({ value: null }),
@@ -56,10 +86,11 @@ export class ConfigurationFormComponent extends CdForm implements OnInit {
       values: new UntypedFormGroup({}),
       default: new UntypedFormControl({ value: null }),
       daemon_default: new UntypedFormControl({ value: null }),
-      services: new UntypedFormControl([])
+      services: new UntypedFormControl([]),
+      clientEntries: new UntypedFormArray([])
     };
 
-    this.availSections.forEach((section) => {
+    this.allSections.forEach((section) => {
       formControls.values.addControl(section, new UntypedFormControl(null));
     });
 
@@ -67,16 +98,33 @@ export class ConfigurationFormComponent extends CdForm implements OnInit {
   }
 
   ngOnInit() {
-    this.route.params.subscribe((params: { name: string }) => {
-      const configName = params.name;
-      this.configService.get(configName).subscribe((resp: ConfigFormModel) => {
-        this.setResponse(resp);
-        this.loadingReady();
+    this.route.params
+      .pipe(
+        switchMap((params: Params) => {
+          const configName = params['name'] as string;
+          return this.configService.get(configName);
+        }),
+        switchMap((resp) => {
+          this.setResponse(resp as ConfigFormModel);
+          return this.dataGatewayService
+            .list('api.cluster.user@1.0')
+            .pipe(catchError(() => of([])));
+        })
+      )
+      .subscribe({
+        next: (users: CephUser[]) => {
+          this.clientEntities = users;
+          this.prepareClientEntityOptions();
+          this.initializeClientEntries();
+          this.loadingReady();
+        },
+        error: () => {
+          this.loadingReady();
+        }
       });
-    });
   }
 
-  getValidators(configOption: any): ValidatorFn[] {
+  getValidators(configOption: ConfigFormModel): ValidatorFn[] | undefined {
     const typeValidators = ConfigOptionTypes.getTypeValidators(configOption);
     if (typeValidators) {
       this.patternHelpText = typeValidators.patternHelpText;
@@ -95,12 +143,11 @@ export class ConfigurationFormComponent extends CdForm implements OnInit {
     return undefined;
   }
 
-  getStep(type: string, value: number): number | undefined {
-    return ConfigOptionTypes.getTypeStep(type, value);
-  }
-
   setResponse(response: ConfigFormModel) {
     this.response = response;
+
+    this.availSections = [...this.allSections];
+
     const validators = this.getValidators(response);
     this.configForm.get('name').setValue(response.name);
     this.configForm.get('desc').setValue(response.desc);
@@ -111,23 +158,25 @@ export class ConfigurationFormComponent extends CdForm implements OnInit {
 
     if (this.response.value) {
       this.response.value.forEach((value) => {
-        if (!this.availSections.includes(value.section)) {
+        if (value.section.startsWith('client')) {
           return;
         }
-        let sectionValue = null;
-        if (value.value === 'true') {
-          sectionValue = true;
-        } else if (value.value === 'false') {
-          sectionValue = false;
-        } else {
-          sectionValue = value.value;
+
+        // If section is not in standard sections, add it dynamically (e.g., daemon IDs)
+        if (!this.availSections.includes(value.section)) {
+          this.availSections.push(value.section);
+          const valuesGroup = this.configForm.get('values') as UntypedFormGroup;
+          valuesGroup.addControl(value.section, new UntypedFormControl(null));
         }
-        this.configForm.get('values').get(value.section).setValue(sectionValue);
+        const sectionValue = this.parseConfigValue(value.value);
+        this.configForm.get('values')?.get(value.section)?.setValue(sectionValue);
       });
     }
     this.forceUpdate = !this.response.can_update_at_runtime && response.name.includes(RGW);
     this.availSections.forEach((section) => {
-      this.configForm.get('values').get(section).setValidators(validators);
+      if (validators) {
+        this.configForm.get('values')?.get(section)?.setValidators(validators);
+      }
     });
 
     const currentType = ConfigOptionTypes.getType(response.type);
@@ -136,20 +185,211 @@ export class ConfigurationFormComponent extends CdForm implements OnInit {
     this.humanReadableType = currentType.humanReadable;
   }
 
+  initializeClientEntries() {
+    while (this.clientEntries.length) {
+      this.clientEntries.removeAt(0);
+    }
+
+    const validators = this.getValidators(this.response);
+    const allValues = this.response.value || [];
+
+    // Filter for client and client.* values
+    const clientSpecificValues = allValues.filter((value) => value.section.startsWith('client'));
+
+    // Tracking unique sections to prevent duplicates
+    const uniqueSections = new Set<string>();
+    const uniqueClientValues: Array<{ section: string; value: string }> = [];
+
+    clientSpecificValues.forEach((value) => {
+      if (!uniqueSections.has(value.section)) {
+        uniqueSections.add(value.section);
+        uniqueClientValues.push(value);
+
+        // Adding any custom sections that aren't in the dropdown to clientEntityOptions
+        const sectionExists = this.clientEntityOptions.some(
+          (item) => item.content === value.section
+        );
+        if (!sectionExists) {
+          this.clientEntityOptions.push({
+            content: value.section,
+            name: value.section
+          });
+        }
+      }
+    });
+
+    if (uniqueClientValues.length > 0) {
+      uniqueClientValues.forEach((value) => {
+        this.addClientEntryWithValue(value.section, value.value, validators);
+      });
+    }
+  }
+
+  private addClientEntryWithValue(section: string, value: string, validators?: ValidatorFn[]) {
+    const entryValue = this.parseConfigValue(value);
+    this.createClientFormGroup(section, entryValue, validators);
+  }
+
+  private parseConfigValue(value: string | boolean | number): string | boolean | number {
+    // Handle non-string values
+    if (typeof value !== 'string') {
+      return value;
+    }
+
+    if (this.response.type === 'bool') {
+      if (value === 'true') {
+        return true;
+      }
+      if (value === 'false') {
+        return false;
+      }
+    }
+
+    if (['uint', 'int', 'size', 'secs', 'float'].includes(this.response.type)) {
+      const trimmedValue = value.trim();
+      if (trimmedValue !== '' && !isNaN(Number(trimmedValue))) {
+        return Number(trimmedValue);
+      }
+    }
+
+    return value;
+  }
+
+  addClientEntry() {
+    const validators = this.getValidators(this.response);
+    this.createClientFormGroup('', null, validators);
+  }
+
+  private createClientFormGroup(
+    clientEntity: string,
+    value: string | boolean | number | null,
+    validators?: ValidatorFn[]
+  ) {
+    const valueValidators = validators
+      ? [Validators.required, ...validators]
+      : [Validators.required];
+
+    const formGroup = new UntypedFormGroup({
+      clientEntity: new UntypedFormControl(clientEntity, [
+        Validators.required,
+        this.clientPrefixValidator(),
+        this.clientEntityUniquenessValidator()
+      ]),
+      value: new UntypedFormControl(value, valueValidators)
+    });
+
+    // Revalidate all sibling rows when this row's clientEntity changes
+    formGroup.get('clientEntity')?.valueChanges.subscribe(() => {
+      this.revalidateClientEntities();
+    });
+
+    this.clientEntries.push(formGroup);
+  }
+
+  removeClientEntry(index: number) {
+    this.clientEntries.removeAt(index);
+    // Revalidate remaining entries to update duplicate status
+    this.revalidateClientEntities();
+    this.cd.detectChanges();
+  }
+
+  prepareClientEntityOptions() {
+    this.clientEntityOptions = this.clientEntities
+      .filter((user) => user.entity.startsWith('client.'))
+      .map((user) => ({
+        content: user.entity,
+        name: user.entity
+      }));
+  }
+
+  private clientPrefixValidator(): ValidatorFn {
+    return (control: AbstractControl): ValidationErrors | null => {
+      const value = control.value;
+      if (!value) {
+        return null;
+      }
+      if (typeof value === 'string' && !value.startsWith('client.') && value !== 'client') {
+        return { clientPrefix: { value: value, requiredPrefix: 'client' } };
+      }
+      return null;
+    };
+  }
+
+  private clientEntityUniquenessValidator(): ValidatorFn {
+    return (control: AbstractControl): ValidationErrors | null => {
+      const value = control.value;
+      if (!value) {
+        return null;
+      }
+
+      const duplicateCount = this.clientEntries.controls.filter((formGroup) => {
+        const entityControl = (formGroup as UntypedFormGroup).get('clientEntity');
+        return entityControl?.value === value;
+      }).length;
+
+      if (duplicateCount > 1) {
+        return { clientEntityDuplicate: { value: value } };
+      }
+
+      return null;
+    };
+  }
+
+  private revalidateClientEntities(): void {
+    // Revalidate all clientEntity controls to update duplicate status
+    this.clientEntries.controls.forEach((formGroup) => {
+      const entityControl = (formGroup as UntypedFormGroup).get('clientEntity');
+      entityControl?.updateValueAndValidity({ emitEvent: false });
+    });
+  }
+
+  getStep(type: string, value: any): number | undefined {
+    return ConfigOptionTypes.getTypeStep(type, value);
+  }
+
+  private hasValue(value: string | boolean | null | undefined): boolean {
+    return value !== null && value !== undefined && value !== '';
+  }
+
   createRequest(): ConfigFormCreateRequestModel | null {
-    const values: any[] = [];
+    const values: Array<{ section: string; value: string | boolean }> = [];
 
+    // Handle standard sections
     this.availSections.forEach((section) => {
-      const sectionValue = this.configForm.getValue(section);
+      const sectionValue = this.configForm.get('values')?.get(section)?.value;
       const hadValue = this.response?.value?.some((v) => v.section === section);
 
-      if (sectionValue !== null && sectionValue !== undefined && sectionValue !== '') {
+      if (this.hasValue(sectionValue)) {
         values.push({ section: section, value: sectionValue });
       } else if (hadValue) {
         values.push({ section: section, value: '' });
       }
     });
 
+    this.clientEntries.controls.forEach((control: AbstractControl) => {
+      const formGroup = control as UntypedFormGroup;
+      const clientEntity = formGroup.get('clientEntity')?.value as string;
+      const value = formGroup.get('value')?.value;
+
+      if (clientEntity && this.hasValue(value)) {
+        values.push({ section: clientEntity, value: value });
+      }
+    });
+
+    // Remove values from client.* that were previously set but are now removed
+    const currentClientSections = this.clientEntries.controls
+      .map(
+        (control: AbstractControl) =>
+          (control as UntypedFormGroup).get('clientEntity')?.value as string
+      )
+      .filter((clientEntity: string | undefined): clientEntity is string => !!clientEntity);
+
+    this.response?.value?.forEach((value) => {
+      if (value.section.startsWith('client') && !currentClientSections.includes(value.section)) {
+        values.push({ section: value.section, value: '' });
+      }
+    });
+
     if (!_.isEqual(this.response.value, values)) {
       const request = new ConfigFormCreateRequestModel();
       request.name = this.configForm.getValue('name');
@@ -180,18 +420,18 @@ export class ConfigurationFormComponent extends CdForm implements OnInit {
     const request = this.createRequest();
 
     if (request) {
-      this.configService.create(request).subscribe(
-        () => {
+      this.configService.create(request).subscribe({
+        next: () => {
           this.notificationService.show(
             NotificationType.success,
             $localize`Updated config option ${request.name}`
           );
           this.router.navigate(['/configuration']);
         },
-        () => {
+        error: () => {
           this.configForm.setErrors({ cdSubmitButton: true });
         }
-      );
+      });
     } else {
       this.router.navigate(['/configuration']);
     }