From: Naman Munet Date: Fri, 17 Jul 2026 09:52:49 +0000 (+0530) Subject: mgr/dashboard: Edit should support setting one or more rgw service configuration... X-Git-Url: http://git-server-git.apps.pok.os.sepia.ceph.com/?a=commitdiff_plain;h=967e8886827d149f0c774a3501230cb8f31b06d1;p=ceph.git mgr/dashboard: Edit should support setting one or more rgw service configuration individually, when multiple rgw service exist 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 --- diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.e2e-spec.ts index b71719c4396..34986115f68 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.e2e-spec.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.e2e-spec.ts @@ -43,8 +43,7 @@ describe('Configuration page', () => { ['mon', '2'], ['mgr', '3'], ['osd', '4'], - ['mds', '5'], - ['client', '6'] + ['mds', '5'] ); }); diff --git a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts index 6c2b4496528..b388b24ded7 100644 --- a/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts +++ b/src/pybind/mgr/dashboard/frontend/cypress/e2e/cluster/configuration.po.ts @@ -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); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html index 28089e5c5a3..705e91aa3d1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.html @@ -143,118 +143,158 @@ @for (section of availSections; track section) { - @if (type === 'bool') { +
+ +
+ } + + + +
+

Client Configuration

+
+ +
+

+ Configure this option for specific client entities. Select from the dropdown or enter + custom values like + + client.rgw.my-daemon + or + client.admin + . +

+
+ +
+ @for (entry of clientEntries.controls; track entry; let i = $index) { +
- - - - - - + Client Entity + + + + @for (option of clientEntityOptions; track option.content) { + + } + +
- } - @if (type !== 'bool' && inputType === 'number') {
- - - - - +
- } - @if (type !== 'bool' && inputType !== 'number') {
- - {{ section }} - - - - - + +
- } +
} + +
+ +
@@ -264,11 +304,131 @@ + + + @if (control) { + @if (type === 'bool') { + + + + + + + + + } + + @if (type !== 'bool' && inputType === 'number') { + + + + + + } + + @if (type !== 'bool' && inputType !== 'number') { + + {{ label }} + + + + + + } + } + + @if (control?.invalid && (control.dirty || control.touched)) { + @if (control.hasError('required')) { +
+ This field is required. +
+ } + @if (control.hasError('clientPrefix')) { +
+ Client entity must start with 'client.'
+ (e.g., client.admin, client.rgw.my-daemon) +
+ } + @if (control.hasError('clientEntityDuplicate')) { +
+ This client entity is already configured.
+ Each client entity can only be configured once. +
+ } @if (control.hasError('min')) { { 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(); + }); + }); + }); }); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts index c98f93dc93b..9b71debfc40 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.component.ts @@ -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(); + 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']); }