['mon', '2'],
['mgr', '3'],
['osd', '4'],
- ['mds', '5'],
- ['client', '6']
+ ['mds', '5']
);
});
*/
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);
</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"
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';
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();
+ });
+ });
+ });
});
-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';
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',
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,
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 }),
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));
});
}
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;
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);
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);
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');
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']);
}