]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Add config option component
authorTatjana Dehler <tdehler@suse.com>
Wed, 6 Mar 2019 15:42:28 +0000 (16:42 +0100)
committerTatjana Dehler <tdehler@suse.com>
Tue, 28 May 2019 14:14:33 +0000 (16:14 +0200)
This commit adds an initial config option component in order to move
the HTML template and the config option types related code to an own
centralized place to be re-usable by other components

Signed-off-by: Tatjana Dehler <tdehler@suse.com>
15 files changed:
src/pybind/mgr/dashboard/frontend/src/app/ceph/block/rbd-configuration-form/rbd-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
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.model.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.types.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.types.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/components.module.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts [new file with mode: 0644]

index 63a5c88fc7c893a22a8733d957651c4c81aab12e..7a1a59b161931fa2e2eeebd61270c37ae5a6fa74 100644 (file)
                 type="button"
                 data-toggle="button"
                 [ngClass]="{'active': isDisabled(option.name)}"
-                tooltip="Remove the local configuration value. The parent configuration value will be inherited and used instead."
-                containerClass="tooltip-wide"
-                [delay]="1000"
-                i18n-tooltip
+                title="Remove the local configuration value. The parent configuration value will be inherited and used instead."
+                i18n-title
                 (click)="reset(option.name)">
                 <i class="fa fa-eraser"
                    aria-hidden="true"></i>
index a80f9bd85a371b6cb8c2e5bf372cff6e6f71d19c..1c73c559c07ebaffe904c95e9478eb497b991835 100644 (file)
@@ -7,9 +7,9 @@ import { RouterTestingModule } from '@angular/router/testing';
 import { ToastModule } from 'ng2-toastr';
 
 import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper';
+import { ConfigFormModel } from '../../../../shared/components/config-option/config-option.model';
 import { SharedModule } from '../../../../shared/shared.module';
 import { ConfigurationFormComponent } from './configuration-form.component';
-import { ConfigFormModel } from './configuration-form.model';
 
 describe('ConfigurationFormComponent', () => {
   let component: ConfigurationFormComponent;
@@ -104,61 +104,4 @@ describe('ConfigurationFormComponent', () => {
       expect(component.maxValue).toBe(5.2);
     });
   });
-
-  describe('getStep', () => {
-    it('should return the correct step for type uint and value 0', () => {
-      const ret = component.getStep('uint', 0);
-      expect(ret).toBe(1);
-    });
-
-    it('should return the correct step for type int and value 1', () => {
-      const ret = component.getStep('int', 1);
-      expect(ret).toBe(1);
-    });
-
-    it('should return the correct step for type int and value null', () => {
-      const ret = component.getStep('int', null);
-      expect(ret).toBe(1);
-    });
-
-    it('should return the correct step for type size and value 2', () => {
-      const ret = component.getStep('size', 2);
-      expect(ret).toBe(1);
-    });
-
-    it('should return the correct step for type secs and value 3', () => {
-      const ret = component.getStep('secs', 3);
-      expect(ret).toBe(1);
-    });
-
-    it('should return the correct step for type float and value 1', () => {
-      const ret = component.getStep('float', 1);
-      expect(ret).toBe(0.1);
-    });
-
-    it('should return the correct step for type float and value 0.1', () => {
-      const ret = component.getStep('float', 0.1);
-      expect(ret).toBe(0.1);
-    });
-
-    it('should return the correct step for type float and value 0.02', () => {
-      const ret = component.getStep('float', 0.02);
-      expect(ret).toBe(0.01);
-    });
-
-    it('should return the correct step for type float and value 0.003', () => {
-      const ret = component.getStep('float', 0.003);
-      expect(ret).toBe(0.001);
-    });
-
-    it('should return the correct step for type float and value null', () => {
-      const ret = component.getStep('float', null);
-      expect(ret).toBe(0.1);
-    });
-
-    it('should return undefined for unknown type', () => {
-      const ret = component.getStep('unknown', 1);
-      expect(ret).toBeUndefined();
-    });
-  });
 });
index 11063864b65abe104036a7969b055ca558868e6b..0c05f1fc410ccca49bf76f4a9ab8270f210112a2 100644 (file)
@@ -6,12 +6,12 @@ import { I18n } from '@ngx-translate/i18n-polyfill';
 import * as _ from 'lodash';
 
 import { ConfigurationService } from '../../../../shared/api/configuration.service';
+import { ConfigFormModel } from '../../../../shared/components/config-option/config-option.model';
+import { ConfigOptionTypes } from '../../../../shared/components/config-option/config-option.types';
 import { NotificationType } from '../../../../shared/enum/notification-type.enum';
 import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
 import { NotificationService } from '../../../../shared/services/notification.service';
 import { ConfigFormCreateRequestModel } from './configuration-form-create-request.model';
-import { ConfigFormModel } from './configuration-form.model';
-import { ConfigOptionTypes } from './configuration-form.types';
 
 @Component({
   selector: 'cd-configuration-form',
@@ -84,26 +84,7 @@ export class ConfigurationFormComponent implements OnInit {
   }
 
   getStep(type: string, value: number): number | undefined {
-    const numberTypes = ['uint', 'int', 'size', 'secs'];
-
-    if (numberTypes.includes(type)) {
-      return 1;
-    }
-
-    if (type === 'float') {
-      if (value !== null) {
-        const stringVal = value.toString();
-        if (stringVal.indexOf('.') !== -1) {
-          // Value type float and contains decimal characters
-          const decimal = value.toString().split('.');
-          return Math.pow(10, -decimal[1].length);
-        }
-      }
-
-      return 0.1;
-    }
-
-    return undefined;
+    return ConfigOptionTypes.getTypeStep(type, value);
   }
 
   setResponse(response: ConfigFormModel) {
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.model.ts
deleted file mode 100644 (file)
index d3ebc5f..0000000
+++ /dev/null
@@ -1,12 +0,0 @@
-export class ConfigFormModel {
-  name: string;
-  desc: string;
-  long_desc: string;
-  type: string;
-  value: Array<any>;
-  default: any;
-  daemon_default: any;
-  min: any;
-  max: any;
-  services: Array<string>;
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.types.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.types.spec.ts
deleted file mode 100644 (file)
index d5fa623..0000000
+++ /dev/null
@@ -1,215 +0,0 @@
-import { ConfigFormModel } from './configuration-form.model';
-import { ConfigOptionTypes } from './configuration-form.types';
-
-describe('ConfigOptionTypes', () => {
-  describe('getType', () => {
-    it('should return uint type', () => {
-      const ret = ConfigOptionTypes.getType('uint');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('uint');
-      expect(ret.inputType).toBe('number');
-      expect(ret.humanReadable).toBe('Unsigned integer value');
-      expect(ret.defaultMin).toBe(0);
-      expect(ret.patternHelpText).toBe('The entered value needs to be an unsigned number.');
-      expect(ret.isNumberType).toBe(true);
-      expect(ret.allowsNegative).toBe(false);
-    });
-
-    it('should return int type', () => {
-      const ret = ConfigOptionTypes.getType('int');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('int');
-      expect(ret.inputType).toBe('number');
-      expect(ret.humanReadable).toBe('Integer value');
-      expect(ret.defaultMin).toBeUndefined();
-      expect(ret.patternHelpText).toBe('The entered value needs to be a number.');
-      expect(ret.isNumberType).toBe(true);
-      expect(ret.allowsNegative).toBe(true);
-    });
-
-    it('should return size type', () => {
-      const ret = ConfigOptionTypes.getType('size');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('size');
-      expect(ret.inputType).toBe('number');
-      expect(ret.humanReadable).toBe('Unsigned integer value (>=16bit)');
-      expect(ret.defaultMin).toBe(0);
-      expect(ret.patternHelpText).toBe('The entered value needs to be a unsigned number.');
-      expect(ret.isNumberType).toBe(true);
-      expect(ret.allowsNegative).toBe(false);
-    });
-
-    it('should return secs type', () => {
-      const ret = ConfigOptionTypes.getType('secs');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('secs');
-      expect(ret.inputType).toBe('number');
-      expect(ret.humanReadable).toBe('Number of seconds');
-      expect(ret.defaultMin).toBe(1);
-      expect(ret.patternHelpText).toBe('The entered value needs to be a number >= 1.');
-      expect(ret.isNumberType).toBe(true);
-      expect(ret.allowsNegative).toBe(false);
-    });
-
-    it('should return float type', () => {
-      const ret = ConfigOptionTypes.getType('float');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('float');
-      expect(ret.inputType).toBe('number');
-      expect(ret.humanReadable).toBe('Double value');
-      expect(ret.defaultMin).toBeUndefined();
-      expect(ret.patternHelpText).toBe('The entered value needs to be a number or decimal.');
-      expect(ret.isNumberType).toBe(true);
-      expect(ret.allowsNegative).toBe(true);
-    });
-
-    it('should return str type', () => {
-      const ret = ConfigOptionTypes.getType('str');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('str');
-      expect(ret.inputType).toBe('text');
-      expect(ret.humanReadable).toBe('Text');
-      expect(ret.defaultMin).toBeUndefined();
-      expect(ret.patternHelpText).toBeUndefined();
-      expect(ret.isNumberType).toBe(false);
-      expect(ret.allowsNegative).toBeUndefined();
-    });
-
-    it('should return addr type', () => {
-      const ret = ConfigOptionTypes.getType('addr');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('addr');
-      expect(ret.inputType).toBe('text');
-      expect(ret.humanReadable).toBe('IPv4 or IPv6 address');
-      expect(ret.defaultMin).toBeUndefined();
-      expect(ret.patternHelpText).toBe('The entered value needs to be a valid IP address.');
-      expect(ret.isNumberType).toBe(false);
-      expect(ret.allowsNegative).toBeUndefined();
-    });
-
-    it('should return uuid type', () => {
-      const ret = ConfigOptionTypes.getType('uuid');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('uuid');
-      expect(ret.inputType).toBe('text');
-      expect(ret.humanReadable).toBe('UUID');
-      expect(ret.defaultMin).toBeUndefined();
-      expect(ret.patternHelpText).toBe(
-        'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8'
-      );
-      expect(ret.isNumberType).toBe(false);
-      expect(ret.allowsNegative).toBeUndefined();
-    });
-
-    it('should return bool type', () => {
-      const ret = ConfigOptionTypes.getType('bool');
-      expect(ret).toBeTruthy();
-      expect(ret.name).toBe('bool');
-      expect(ret.inputType).toBe('checkbox');
-      expect(ret.humanReadable).toBe('Boolean value');
-      expect(ret.defaultMin).toBeUndefined();
-      expect(ret.patternHelpText).toBeUndefined();
-      expect(ret.isNumberType).toBe(false);
-      expect(ret.allowsNegative).toBeUndefined();
-    });
-
-    it('should throw an error for unknown type', () => {
-      expect(() => ConfigOptionTypes.getType('unknown')).toThrowError(
-        'Found unknown type "unknown" for config option.'
-      );
-    });
-  });
-
-  describe('getTypeValidators', () => {
-    it('should return two validators for type uint, secs and size', () => {
-      const types = ['uint', 'size', 'secs'];
-
-      types.forEach((valType) => {
-        const configOption = new ConfigFormModel();
-        configOption.type = valType;
-
-        const ret = ConfigOptionTypes.getTypeValidators(configOption);
-        expect(ret).toBeTruthy();
-        expect(ret.validators.length).toBe(2);
-      });
-    });
-
-    it('should return a validator for types float, int, addr and uuid', () => {
-      const types = ['float', 'int', 'addr', 'uuid'];
-
-      types.forEach((valType) => {
-        const configOption = new ConfigFormModel();
-        configOption.type = valType;
-
-        const ret = ConfigOptionTypes.getTypeValidators(configOption);
-        expect(ret).toBeTruthy();
-        expect(ret.validators.length).toBe(1);
-      });
-    });
-
-    it('should return undefined for type bool and str', () => {
-      const types = ['str', 'bool'];
-
-      types.forEach((valType) => {
-        const configOption = new ConfigFormModel();
-        configOption.type = valType;
-
-        const ret = ConfigOptionTypes.getTypeValidators(configOption);
-        expect(ret).toBeUndefined();
-      });
-    });
-
-    it('should return a pattern and a min validator', () => {
-      const configOption = new ConfigFormModel();
-      configOption.type = 'int';
-      configOption.min = 2;
-
-      const ret = ConfigOptionTypes.getTypeValidators(configOption);
-      expect(ret).toBeTruthy();
-      expect(ret.validators.length).toBe(2);
-      expect(ret.min).toBe(2);
-      expect(ret.max).toBeUndefined();
-    });
-
-    it('should return a pattern and a max validator', () => {
-      const configOption = new ConfigFormModel();
-      configOption.type = 'int';
-      configOption.max = 5;
-
-      const ret = ConfigOptionTypes.getTypeValidators(configOption);
-      expect(ret).toBeTruthy();
-      expect(ret.validators.length).toBe(2);
-      expect(ret.min).toBeUndefined();
-      expect(ret.max).toBe(5);
-    });
-
-    it('should return multiple validators', () => {
-      const configOption = new ConfigFormModel();
-      configOption.type = 'float';
-      configOption.max = 5.2;
-      configOption.min = 1.5;
-
-      const ret = ConfigOptionTypes.getTypeValidators(configOption);
-      expect(ret).toBeTruthy();
-      expect(ret.validators.length).toBe(3);
-      expect(ret.min).toBe(1.5);
-      expect(ret.max).toBe(5.2);
-    });
-
-    it(
-      'should return a pattern help text for type uint, int, size, secs, ' + 'float, addr and uuid',
-      () => {
-        const types = ['uint', 'int', 'size', 'secs', 'float', 'addr', 'uuid'];
-
-        types.forEach((valType) => {
-          const configOption = new ConfigFormModel();
-          configOption.type = valType;
-
-          const ret = ConfigOptionTypes.getTypeValidators(configOption);
-          expect(ret).toBeTruthy();
-          expect(ret.patternHelpText).toBeDefined();
-        });
-      }
-    );
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.types.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/configuration/configuration-form/configuration-form.types.ts
deleted file mode 100644 (file)
index ed8a3b7..0000000
+++ /dev/null
@@ -1,120 +0,0 @@
-import { Validators } from '@angular/forms';
-import { CdValidators } from '../../../../shared/forms/cd-validators';
-import { ConfigFormModel } from './configuration-form.model';
-
-import * as _ from 'lodash';
-
-export class ConfigOptionTypes {
-  // TODO: I18N
-  private static knownTypes: Array<any> = [
-    {
-      name: 'uint',
-      inputType: 'number',
-      humanReadable: 'Unsigned integer value',
-      defaultMin: 0,
-      patternHelpText: 'The entered value needs to be an unsigned number.',
-      isNumberType: true,
-      allowsNegative: false
-    },
-    {
-      name: 'int',
-      inputType: 'number',
-      humanReadable: 'Integer value',
-      patternHelpText: 'The entered value needs to be a number.',
-      isNumberType: true,
-      allowsNegative: true
-    },
-    {
-      name: 'size',
-      inputType: 'number',
-      humanReadable: 'Unsigned integer value (>=16bit)',
-      defaultMin: 0,
-      patternHelpText: 'The entered value needs to be a unsigned number.',
-      isNumberType: true,
-      allowsNegative: false
-    },
-    {
-      name: 'secs',
-      inputType: 'number',
-      humanReadable: 'Number of seconds',
-      defaultMin: 1,
-      patternHelpText: 'The entered value needs to be a number >= 1.',
-      isNumberType: true,
-      allowsNegative: false
-    },
-    {
-      name: 'float',
-      inputType: 'number',
-      humanReadable: 'Double value',
-      patternHelpText: 'The entered value needs to be a number or decimal.',
-      isNumberType: true,
-      allowsNegative: true
-    },
-    { name: 'str', inputType: 'text', humanReadable: 'Text', isNumberType: false },
-    {
-      name: 'addr',
-      inputType: 'text',
-      humanReadable: 'IPv4 or IPv6 address',
-      patternHelpText: 'The entered value needs to be a valid IP address.',
-      isNumberType: false
-    },
-    {
-      name: 'uuid',
-      inputType: 'text',
-      humanReadable: 'UUID',
-      patternHelpText:
-        'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8',
-      isNumberType: false
-    },
-    { name: 'bool', inputType: 'checkbox', humanReadable: 'Boolean value', isNumberType: false }
-  ];
-
-  public static getType(type: string): any {
-    const currentType = _.find(this.knownTypes, (t) => {
-      return t.name === type;
-    });
-
-    if (currentType !== undefined) {
-      return currentType;
-    }
-
-    throw new Error('Found unknown type "' + type + '" for config option.');
-  }
-
-  public static getTypeValidators(configOption: ConfigFormModel): any {
-    const typeParams = ConfigOptionTypes.getType(configOption.type);
-
-    if (typeParams.name === 'bool' || typeParams.name === 'str') {
-      return;
-    }
-
-    const typeValidators = { validators: [], patternHelpText: typeParams.patternHelpText };
-
-    if (typeParams.isNumberType) {
-      if (configOption.max && configOption.max !== '') {
-        typeValidators['max'] = configOption.max;
-        typeValidators.validators.push(Validators.max(configOption.max));
-      }
-
-      if (configOption.min && configOption.min !== '') {
-        typeValidators['min'] = configOption.min;
-        typeValidators.validators.push(Validators.min(configOption.min));
-      } else if ('defaultMin' in typeParams) {
-        typeValidators['min'] = typeParams.defaultMin;
-        typeValidators.validators.push(Validators.min(typeParams.defaultMin));
-      }
-
-      if (configOption.type === 'float') {
-        typeValidators.validators.push(CdValidators.decimalNumber());
-      } else {
-        typeValidators.validators.push(CdValidators.number(typeParams.allowsNegative));
-      }
-    } else if (configOption.type === 'addr') {
-      typeValidators.validators = [CdValidators.ip()];
-    } else if (configOption.type === 'uuid') {
-      typeValidators.validators = [CdValidators.uuid()];
-    }
-
-    return typeValidators;
-  }
-}
index 17e98894938fd5247edfdabc7b92aee8cd6fd5ae..d4a253c54fe51c877257d0eafa54cd8fd5b201bc 100755 (executable)
@@ -7,10 +7,10 @@ import { BsModalRef } from 'ngx-bootstrap/modal';
 
 import { ConfigurationService } from '../../../../shared/api/configuration.service';
 import { OsdService } from '../../../../shared/api/osd.service';
+import { ConfigOptionTypes } from '../../../../shared/components/config-option/config-option.types';
 import { NotificationType } from '../../../../shared/enum/notification-type.enum';
 import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
 import { NotificationService } from '../../../../shared/services/notification.service';
-import { ConfigOptionTypes } from '../../configuration/configuration-form/configuration-form.types';
 
 @Component({
   selector: 'cd-osd-recv-speed-modal',
index efc94139b23027c1f52a32d78f42ab593a12fc32..e2ad026e218d03db363ce300d8c99ff545acc111 100644 (file)
@@ -12,6 +12,7 @@ import { TooltipModule } from 'ngx-bootstrap/tooltip';
 import { DirectivesModule } from '../directives/directives.module';
 import { PipesModule } from '../pipes/pipes.module';
 import { BackButtonComponent } from './back-button/back-button.component';
+import { ConfigOptionComponent } from './config-option/config-option.component';
 import { ConfirmationModalComponent } from './confirmation-modal/confirmation-modal.component';
 import { CriticalConfirmationModalComponent } from './critical-confirmation-modal/critical-confirmation-modal.component';
 import { ErrorPanelComponent } from './error-panel/error-panel.component';
@@ -63,7 +64,8 @@ import { WarningPanelComponent } from './warning-panel/warning-panel.component';
     GrafanaComponent,
     SelectComponent,
     BackButtonComponent,
-    RefreshSelectorComponent
+    RefreshSelectorComponent,
+    ConfigOptionComponent
   ],
   providers: [],
   exports: [
@@ -82,7 +84,8 @@ import { WarningPanelComponent } from './warning-panel/warning-panel.component';
     LanguageSelectorComponent,
     GrafanaComponent,
     SelectComponent,
-    RefreshSelectorComponent
+    RefreshSelectorComponent,
+    ConfigOptionComponent
   ],
   entryComponents: [ModalComponent, CriticalConfirmationModalComponent, ConfirmationModalComponent]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.html
new file mode 100644 (file)
index 0000000..0db0441
--- /dev/null
@@ -0,0 +1,72 @@
+<div [formGroup]="optionsFormGroup">
+  <div *ngFor="let option of options; let last = last">
+    <div class="form-group"
+         [ngClass]="{'has-error': optionsForm.showError(option.name, optionsFormDir)}"
+         *ngIf="option.type === 'bool'">
+      <label class="col-sm-6 control-label"
+             [for]="option.name">
+        {{ option.text }}
+        <br>
+        <span class="text-muted">
+          {{ option.desc }}
+          <cd-helper *ngIf="option.long_desc">
+            {{ option.long_desc }}</cd-helper>
+        </span>
+      </label>
+      <div class="col-sm-6 checkbox-primary checkbox">
+        <input type="checkbox"
+               [id]="option.name"
+               [formControlName]="option.name">
+        <label></label>
+      </div>
+    </div>
+    <div class="form-group"
+         [ngClass]="{'has-error': optionsForm.showError(option.name, optionsFormDir)}"
+         *ngIf="option.type !== 'bool'">
+      <label class="col-sm-6 control-label"
+             [for]="option.name">{{ option.text }}
+        <br>
+        <span class="text-muted">
+          {{ option.desc }}
+          <cd-helper *ngIf="option.long_desc">
+            {{ option.long_desc }}</cd-helper>
+        </span>
+      </label>
+      <div class="col-sm-6">
+        <div class="input-group">
+          <input class="form-control"
+                 [type]="option.additionalTypeInfo.inputType"
+                 [id]="option.name"
+                 [placeholder]="option.additionalTypeInfo.humanReadable"
+                 [formControlName]="option.name"
+                 [step]="getStep(option.type, optionsForm.getValue(option.name))">
+          <span class="input-group-btn"
+                *ngIf="optionsFormShowReset">
+            <button class="btn btn-default"
+                    type="button"
+                    data-toggle="button"
+                    title="Remove the custom configuration value. The default configuration will be inherited and used instead."
+                    (click)="resetValue(option.name)"
+                    i18n-title>
+              <i class="fa fa-eraser"
+                 aria-hidden="true"></i>
+            </button>
+          </span>
+        </div>
+        <span class="help-block"
+              *ngIf="optionsForm.showError(option.name, optionsFormDir, 'pattern')">
+          {{ option.additionalTypeInfo.patternHelpText }}</span>
+        <span class="help-block"
+              *ngIf="optionsForm.showError(option.name, optionsFormDir, 'invalidUuid')">
+          {{ option.additionalTypeInfo.patternHelpText }}</span>
+        <span class="help-block"
+              *ngIf="optionsForm.showError(option.name, optionsFormDir, 'max')"
+              i18n>The entered value is too high! It must not be greater than {{ option.maxValue }}.</span>
+        <span class="help-block"
+              *ngIf="optionsForm.showError(option.name, optionsFormDir, 'min')"
+              i18n>The entered value is too low! It must not be lower than {{ option.minValue }}.</span>
+      </div>
+    </div>
+    <hr *ngIf="!last">
+  </div>
+</div>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.scss
new file mode 100644 (file)
index 0000000..7c1347e
--- /dev/null
@@ -0,0 +1,30 @@
+hr {
+  margin-top: 5px;
+  margin-bottom: 5px;
+}
+
+.control-label {
+  text-align: left;
+}
+
+.checkbox-primary {
+  input {
+    width: 23px;
+    height: 15px;
+    margin-left: 0;
+    cursor: pointer;
+  }
+  label {
+    &:before,
+    &:after {
+      margin-left: 0;
+    }
+    cursor: auto;
+  }
+}
+
+.form-group {
+  .col-sm-6 {
+    padding-top: 7px;
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.spec.ts
new file mode 100644 (file)
index 0000000..bc2d1ce
--- /dev/null
@@ -0,0 +1,295 @@
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import * as _ from 'lodash';
+import { PopoverModule } from 'ngx-bootstrap/popover';
+import { of as observableOf } from 'rxjs';
+
+import { configureTestBed } from '../../../../testing/unit-test-helper';
+import { ConfigurationService } from '../../api/configuration.service';
+import { CdFormGroup } from '../../forms/cd-form-group';
+import { HelperComponent } from '../helper/helper.component';
+import { ConfigOptionComponent } from './config-option.component';
+
+describe('ConfigOptionComponent', () => {
+  let component: ConfigOptionComponent;
+  let fixture: ComponentFixture<ConfigOptionComponent>;
+  let configurationService: ConfigurationService;
+  let oNames: Array<string>;
+
+  configureTestBed({
+    declarations: [ConfigOptionComponent, HelperComponent],
+    imports: [PopoverModule.forRoot(), ReactiveFormsModule, HttpClientTestingModule],
+    providers: [ConfigurationService]
+  });
+
+  beforeEach(() => {
+    fixture = TestBed.createComponent(ConfigOptionComponent);
+    component = fixture.componentInstance;
+    fixture.detectChanges();
+    configurationService = TestBed.get(ConfigurationService);
+
+    const configOptions = [
+      {
+        name: 'osd_scrub_auto_repair_num_errors',
+        type: 'uint',
+        level: 'advanced',
+        desc: 'Maximum number of detected errors to automatically repair',
+        long_desc: '',
+        default: 5,
+        daemon_default: '',
+        tags: [],
+        services: [],
+        see_also: ['osd_scrub_auto_repair'],
+        min: '',
+        max: '',
+        can_update_at_runtime: true,
+        flags: []
+      },
+      {
+        name: 'osd_debug_deep_scrub_sleep',
+        type: 'float',
+        level: 'dev',
+        desc:
+          'Inject an expensive sleep during deep scrub IO to make it easier to induce preemption',
+        long_desc: '',
+        default: 0,
+        daemon_default: '',
+        tags: [],
+        services: [],
+        see_also: [],
+        min: '',
+        max: '',
+        can_update_at_runtime: true,
+        flags: []
+      },
+      {
+        name: 'osd_heartbeat_interval',
+        type: 'int',
+        level: 'advanced',
+        desc: 'Interval (in seconds) between peer pings',
+        long_desc: '',
+        default: 6,
+        daemon_default: '',
+        tags: [],
+        services: [],
+        see_also: [],
+        min: 1,
+        max: 86400,
+        can_update_at_runtime: true,
+        flags: [],
+        value: [
+          {
+            section: 'osd',
+            value: 6
+          }
+        ]
+      },
+      {
+        name: 'bluestore_compression_algorithm',
+        type: 'str',
+        level: 'advanced',
+        desc: 'Default compression algorithm to use when writing object data',
+        long_desc:
+          'This controls the default compressor to use (if any) if the ' +
+          'per-pool property is not set.  Note that zstd is *not* recommended for ' +
+          'bluestore due to high CPU overhead when compressing small amounts of data.',
+        default: 'snappy',
+        daemon_default: '',
+        tags: [],
+        services: [],
+        see_also: [],
+        enum_values: ['', 'snappy', 'zlib', 'zstd', 'lz4'],
+        min: '',
+        max: '',
+        can_update_at_runtime: true,
+        flags: ['runtime']
+      },
+      {
+        name: 'rbd_discard_on_zeroed_write_same',
+        type: 'bool',
+        level: 'advanced',
+        desc: 'discard data on zeroed write same instead of writing zero',
+        long_desc: '',
+        default: true,
+        daemon_default: '',
+        tags: [],
+        services: ['rbd'],
+        see_also: [],
+        min: '',
+        max: '',
+        can_update_at_runtime: true,
+        flags: []
+      },
+      {
+        name: 'rbd_journal_max_payload_bytes',
+        type: 'size',
+        level: 'advanced',
+        desc: 'maximum journal payload size before splitting',
+        long_desc: '',
+        daemon_default: '',
+        tags: [],
+        services: ['rbd'],
+        see_also: [],
+        min: '',
+        max: '',
+        can_update_at_runtime: true,
+        flags: [],
+        default: '16384'
+      },
+      {
+        name: 'cluster_addr',
+        type: 'addr',
+        level: 'basic',
+        desc: 'cluster-facing address to bind to',
+        long_desc: '',
+        daemon_default: '',
+        tags: ['network'],
+        services: ['osd'],
+        see_also: [],
+        min: '',
+        max: '',
+        can_update_at_runtime: false,
+        flags: [],
+        default: '-'
+      },
+      {
+        name: 'fsid',
+        type: 'uuid',
+        level: 'basic',
+        desc: 'cluster fsid (uuid)',
+        long_desc: '',
+        daemon_default: '',
+        tags: ['service'],
+        services: ['common'],
+        see_also: [],
+        min: '',
+        max: '',
+        can_update_at_runtime: false,
+        flags: ['no_mon_update'],
+        default: '00000000-0000-0000-0000-000000000000'
+      },
+      {
+        name: 'mgr_tick_period',
+        type: 'secs',
+        level: 'advanced',
+        desc: 'Period in seconds of beacon messages to monitor',
+        long_desc: '',
+        daemon_default: '',
+        tags: [],
+        services: ['mgr'],
+        see_also: [],
+        min: '',
+        max: '',
+        can_update_at_runtime: true,
+        flags: [],
+        default: '2'
+      }
+    ];
+
+    spyOn(configurationService, 'filter').and.returnValue(observableOf(configOptions));
+    oNames = _.map(configOptions, 'name');
+    component.optionNames = oNames;
+    component.optionsForm = new CdFormGroup({});
+    component.optionsFormGroupName = 'testFormGroupName';
+    component.ngOnInit();
+  });
+
+  it('should create', () => {
+    expect(component).toBeTruthy();
+  });
+
+  describe('optionNameToText', () => {
+    it('should format config option names correctly', () => {
+      const configOptionNames = {
+        osd_scrub_auto_repair_num_errors: 'Scrub Auto Repair Num Errors',
+        osd_debug_deep_scrub_sleep: 'Debug Deep Scrub Sleep',
+        osd_heartbeat_interval: 'Heartbeat Interval',
+        bluestore_compression_algorithm: 'Bluestore Compression Algorithm',
+        rbd_discard_on_zeroed_write_same: 'Rbd Discard On Zeroed Write Same',
+        rbd_journal_max_payload_bytes: 'Rbd Journal Max Payload Bytes',
+        cluster_addr: 'Cluster Addr',
+        fsid: 'Fsid',
+        mgr_tick_period: 'Tick Period'
+      };
+
+      component.options.forEach((option) => {
+        expect(option.text).toEqual(configOptionNames[option.name]);
+      });
+    });
+  });
+
+  describe('createForm', () => {
+    it('should set the optionsFormGroupName correctly', () => {
+      expect(component.optionsFormGroupName).toEqual('testFormGroupName');
+    });
+
+    it('should create a FormControl for every config option', () => {
+      component.options.forEach((option) => {
+        expect(Object.keys(component.optionsFormGroup.controls)).toContain(option.name);
+      });
+    });
+  });
+
+  describe('loadStorageData', () => {
+    it('should create a list of config options by names', () => {
+      expect(component.options.length).toEqual(9);
+
+      component.options.forEach((option) => {
+        expect(oNames).toContain(option.name);
+      });
+    });
+
+    it('should add all needed attributes to every config option', () => {
+      component.options.forEach((option) => {
+        const optionKeys = Object.keys(option);
+        expect(optionKeys).toContain('text');
+        expect(optionKeys).toContain('additionalTypeInfo');
+        expect(optionKeys).toContain('value');
+
+        if (option.type !== 'bool' && option.type !== 'str') {
+          expect(optionKeys).toContain('patternHelpText');
+        }
+
+        if (option.name === 'osd_heartbeat_interval') {
+          expect(optionKeys).toContain('maxValue');
+          expect(optionKeys).toContain('minValue');
+        }
+      });
+    });
+
+    it('should set minValue and maxValue correctly', () => {
+      component.options.forEach((option) => {
+        if (option.name === 'osd_heartbeat_interval') {
+          expect(option.minValue).toEqual(1);
+          expect(option.maxValue).toEqual(86400);
+        }
+      });
+    });
+
+    it('should set the value attribute correctly', () => {
+      component.options.forEach((option) => {
+        if (option.name === 'osd_heartbeat_interval') {
+          const value = option.value;
+          expect(value).toBeDefined();
+          expect(value).toEqual({ section: 'osd', value: 6 });
+        } else {
+          expect(option.value).toBeUndefined();
+        }
+      });
+    });
+
+    it('should set the FormControl value correctly', () => {
+      component.options.forEach((option) => {
+        const value = component.optionsFormGroup.getValue(option.name);
+        if (option.name === 'osd_heartbeat_interval') {
+          expect(value).toBeDefined();
+          expect(value).toEqual(6);
+        } else {
+          expect(value).toBeNull();
+        }
+      });
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.component.ts
new file mode 100644 (file)
index 0000000..e9d6a11
--- /dev/null
@@ -0,0 +1,118 @@
+import { Component, Input, OnInit } from '@angular/core';
+import { FormControl, NgForm } from '@angular/forms';
+
+import * as _ from 'lodash';
+
+import { ConfigurationService } from '../../api/configuration.service';
+import { CdFormGroup } from '../../forms/cd-form-group';
+import { ConfigOptionTypes } from './config-option.types';
+
+@Component({
+  selector: 'cd-config-option',
+  templateUrl: './config-option.component.html',
+  styleUrls: ['./config-option.component.scss']
+})
+export class ConfigOptionComponent implements OnInit {
+  @Input()
+  optionNames: Array<string> = [];
+  @Input()
+  optionsForm: CdFormGroup = new CdFormGroup({});
+  @Input()
+  optionsFormDir: NgForm = new NgForm([], []);
+  @Input()
+  optionsFormGroupName = '';
+  @Input()
+  optionsFormShowReset = true;
+
+  options: Array<any> = [];
+  optionsFormGroup: CdFormGroup = new CdFormGroup({});
+
+  constructor(private configService: ConfigurationService) {}
+
+  private static optionNameToText(optionName: string): string {
+    const sections = ['mon', 'mgr', 'osd', 'mds', 'client'];
+    return optionName
+      .split('_')
+      .filter((c, index) => index !== 0 || !sections.includes(c))
+      .map((c) => c.charAt(0).toUpperCase() + c.substring(1))
+      .join(' ');
+  }
+
+  ngOnInit() {
+    this.createForm();
+    this.loadStoredData();
+  }
+
+  private createForm() {
+    this.optionsForm.addControl(this.optionsFormGroupName, this.optionsFormGroup);
+    this.optionNames.forEach((optionName) => {
+      this.optionsFormGroup.addControl(optionName, new FormControl(null));
+    });
+  }
+
+  getStep(type: string, value: any): number | undefined {
+    return ConfigOptionTypes.getTypeStep(type, value);
+  }
+
+  private loadStoredData() {
+    this.configService.filter(this.optionNames).subscribe((data: any) => {
+      this.options = data.map((configOption) => {
+        const formControl = this.optionsForm.get(configOption.name);
+        const typeValidators = ConfigOptionTypes.getTypeValidators(configOption);
+        configOption.additionalTypeInfo = ConfigOptionTypes.getType(configOption.type);
+
+        // Set general information and value
+        configOption.text = ConfigOptionComponent.optionNameToText(configOption.name);
+        configOption.value = _.find(configOption.value, (p) => {
+          return p.section === 'osd'; // TODO: Can handle any other section
+        });
+        if (configOption.value) {
+          if (configOption.additionalTypeInfo.name === 'bool') {
+            formControl.setValue(configOption.value.value === 'true');
+          } else {
+            formControl.setValue(configOption.value.value);
+          }
+        }
+
+        // Set type information and validators
+        if (typeValidators) {
+          configOption.patternHelpText = typeValidators.patternHelpText;
+          if ('max' in typeValidators && typeValidators.max !== '') {
+            configOption.maxValue = typeValidators.max;
+          }
+          if ('min' in typeValidators && typeValidators.min !== '') {
+            configOption.minValue = typeValidators.min;
+          }
+          formControl.setValidators(typeValidators.validators);
+        }
+
+        return configOption;
+      });
+    });
+  }
+
+  saveValues() {
+    const options = {};
+    this.optionNames.forEach((optionName) => {
+      const optionValue = this.optionsForm.getValue(optionName);
+      if (optionValue !== null && optionValue !== '') {
+        options[optionName] = {
+          section: 'osd', // TODO: Can handle any other section
+          value: optionValue
+        };
+      }
+    });
+
+    return this.configService.bulkCreate({ options: options });
+  }
+
+  resetValue(optionName: string) {
+    this.configService.delete(optionName, 'osd').subscribe(
+      // TODO: Can handle any other section
+      () => {
+        const formControl = this.optionsForm.get(optionName);
+        formControl.reset();
+      }
+    );
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.model.ts
new file mode 100644 (file)
index 0000000..d3ebc5f
--- /dev/null
@@ -0,0 +1,12 @@
+export class ConfigFormModel {
+  name: string;
+  desc: string;
+  long_desc: string;
+  type: string;
+  value: Array<any>;
+  default: any;
+  daemon_default: any;
+  min: any;
+  max: any;
+  services: Array<string>;
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.spec.ts
new file mode 100644 (file)
index 0000000..8c34111
--- /dev/null
@@ -0,0 +1,272 @@
+import { ConfigFormModel } from './config-option.model';
+import { ConfigOptionTypes } from './config-option.types';
+
+describe('ConfigOptionTypes', () => {
+  describe('getType', () => {
+    it('should return uint type', () => {
+      const ret = ConfigOptionTypes.getType('uint');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('uint');
+      expect(ret.inputType).toBe('number');
+      expect(ret.humanReadable).toBe('Unsigned integer value');
+      expect(ret.defaultMin).toBe(0);
+      expect(ret.patternHelpText).toBe('The entered value needs to be an unsigned number.');
+      expect(ret.isNumberType).toBe(true);
+      expect(ret.allowsNegative).toBe(false);
+    });
+
+    it('should return int type', () => {
+      const ret = ConfigOptionTypes.getType('int');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('int');
+      expect(ret.inputType).toBe('number');
+      expect(ret.humanReadable).toBe('Integer value');
+      expect(ret.defaultMin).toBeUndefined();
+      expect(ret.patternHelpText).toBe('The entered value needs to be a number.');
+      expect(ret.isNumberType).toBe(true);
+      expect(ret.allowsNegative).toBe(true);
+    });
+
+    it('should return size type', () => {
+      const ret = ConfigOptionTypes.getType('size');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('size');
+      expect(ret.inputType).toBe('number');
+      expect(ret.humanReadable).toBe('Unsigned integer value (>=16bit)');
+      expect(ret.defaultMin).toBe(0);
+      expect(ret.patternHelpText).toBe('The entered value needs to be a unsigned number.');
+      expect(ret.isNumberType).toBe(true);
+      expect(ret.allowsNegative).toBe(false);
+    });
+
+    it('should return secs type', () => {
+      const ret = ConfigOptionTypes.getType('secs');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('secs');
+      expect(ret.inputType).toBe('number');
+      expect(ret.humanReadable).toBe('Number of seconds');
+      expect(ret.defaultMin).toBe(1);
+      expect(ret.patternHelpText).toBe('The entered value needs to be a number >= 1.');
+      expect(ret.isNumberType).toBe(true);
+      expect(ret.allowsNegative).toBe(false);
+    });
+
+    it('should return float type', () => {
+      const ret = ConfigOptionTypes.getType('float');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('float');
+      expect(ret.inputType).toBe('number');
+      expect(ret.humanReadable).toBe('Double value');
+      expect(ret.defaultMin).toBeUndefined();
+      expect(ret.patternHelpText).toBe('The entered value needs to be a number or decimal.');
+      expect(ret.isNumberType).toBe(true);
+      expect(ret.allowsNegative).toBe(true);
+    });
+
+    it('should return str type', () => {
+      const ret = ConfigOptionTypes.getType('str');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('str');
+      expect(ret.inputType).toBe('text');
+      expect(ret.humanReadable).toBe('Text');
+      expect(ret.defaultMin).toBeUndefined();
+      expect(ret.patternHelpText).toBeUndefined();
+      expect(ret.isNumberType).toBe(false);
+      expect(ret.allowsNegative).toBeUndefined();
+    });
+
+    it('should return addr type', () => {
+      const ret = ConfigOptionTypes.getType('addr');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('addr');
+      expect(ret.inputType).toBe('text');
+      expect(ret.humanReadable).toBe('IPv4 or IPv6 address');
+      expect(ret.defaultMin).toBeUndefined();
+      expect(ret.patternHelpText).toBe('The entered value needs to be a valid IP address.');
+      expect(ret.isNumberType).toBe(false);
+      expect(ret.allowsNegative).toBeUndefined();
+    });
+
+    it('should return uuid type', () => {
+      const ret = ConfigOptionTypes.getType('uuid');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('uuid');
+      expect(ret.inputType).toBe('text');
+      expect(ret.humanReadable).toBe('UUID');
+      expect(ret.defaultMin).toBeUndefined();
+      expect(ret.patternHelpText).toBe(
+        'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8'
+      );
+      expect(ret.isNumberType).toBe(false);
+      expect(ret.allowsNegative).toBeUndefined();
+    });
+
+    it('should return bool type', () => {
+      const ret = ConfigOptionTypes.getType('bool');
+      expect(ret).toBeTruthy();
+      expect(ret.name).toBe('bool');
+      expect(ret.inputType).toBe('checkbox');
+      expect(ret.humanReadable).toBe('Boolean value');
+      expect(ret.defaultMin).toBeUndefined();
+      expect(ret.patternHelpText).toBeUndefined();
+      expect(ret.isNumberType).toBe(false);
+      expect(ret.allowsNegative).toBeUndefined();
+    });
+
+    it('should throw an error for unknown type', () => {
+      expect(() => ConfigOptionTypes.getType('unknown')).toThrowError(
+        'Found unknown type "unknown" for config option.'
+      );
+    });
+  });
+
+  describe('getTypeValidators', () => {
+    it('should return two validators for type uint, secs and size', () => {
+      const types = ['uint', 'size', 'secs'];
+
+      types.forEach((valType) => {
+        const configOption = new ConfigFormModel();
+        configOption.type = valType;
+
+        const ret = ConfigOptionTypes.getTypeValidators(configOption);
+        expect(ret).toBeTruthy();
+        expect(ret.validators.length).toBe(2);
+      });
+    });
+
+    it('should return a validator for types float, int, addr and uuid', () => {
+      const types = ['float', 'int', 'addr', 'uuid'];
+
+      types.forEach((valType) => {
+        const configOption = new ConfigFormModel();
+        configOption.type = valType;
+
+        const ret = ConfigOptionTypes.getTypeValidators(configOption);
+        expect(ret).toBeTruthy();
+        expect(ret.validators.length).toBe(1);
+      });
+    });
+
+    it('should return undefined for type bool and str', () => {
+      const types = ['str', 'bool'];
+
+      types.forEach((valType) => {
+        const configOption = new ConfigFormModel();
+        configOption.type = valType;
+
+        const ret = ConfigOptionTypes.getTypeValidators(configOption);
+        expect(ret).toBeUndefined();
+      });
+    });
+
+    it('should return a pattern and a min validator', () => {
+      const configOption = new ConfigFormModel();
+      configOption.type = 'int';
+      configOption.min = 2;
+
+      const ret = ConfigOptionTypes.getTypeValidators(configOption);
+      expect(ret).toBeTruthy();
+      expect(ret.validators.length).toBe(2);
+      expect(ret.min).toBe(2);
+      expect(ret.max).toBeUndefined();
+    });
+
+    it('should return a pattern and a max validator', () => {
+      const configOption = new ConfigFormModel();
+      configOption.type = 'int';
+      configOption.max = 5;
+
+      const ret = ConfigOptionTypes.getTypeValidators(configOption);
+      expect(ret).toBeTruthy();
+      expect(ret.validators.length).toBe(2);
+      expect(ret.min).toBeUndefined();
+      expect(ret.max).toBe(5);
+    });
+
+    it('should return multiple validators', () => {
+      const configOption = new ConfigFormModel();
+      configOption.type = 'float';
+      configOption.max = 5.2;
+      configOption.min = 1.5;
+
+      const ret = ConfigOptionTypes.getTypeValidators(configOption);
+      expect(ret).toBeTruthy();
+      expect(ret.validators.length).toBe(3);
+      expect(ret.min).toBe(1.5);
+      expect(ret.max).toBe(5.2);
+    });
+
+    it(
+      'should return a pattern help text for type uint, int, size, secs, ' + 'float, addr and uuid',
+      () => {
+        const types = ['uint', 'int', 'size', 'secs', 'float', 'addr', 'uuid'];
+
+        types.forEach((valType) => {
+          const configOption = new ConfigFormModel();
+          configOption.type = valType;
+
+          const ret = ConfigOptionTypes.getTypeValidators(configOption);
+          expect(ret).toBeTruthy();
+          expect(ret.patternHelpText).toBeDefined();
+        });
+      }
+    );
+  });
+
+  describe('getTypeStep', () => {
+    it('should return the correct step for type uint and value 0', () => {
+      const ret = ConfigOptionTypes.getTypeStep('uint', 0);
+      expect(ret).toBe(1);
+    });
+
+    it('should return the correct step for type int and value 1', () => {
+      const ret = ConfigOptionTypes.getTypeStep('int', 1);
+      expect(ret).toBe(1);
+    });
+
+    it('should return the correct step for type int and value null', () => {
+      const ret = ConfigOptionTypes.getTypeStep('int', null);
+      expect(ret).toBe(1);
+    });
+
+    it('should return the correct step for type size and value 2', () => {
+      const ret = ConfigOptionTypes.getTypeStep('size', 2);
+      expect(ret).toBe(1);
+    });
+
+    it('should return the correct step for type secs and value 3', () => {
+      const ret = ConfigOptionTypes.getTypeStep('secs', 3);
+      expect(ret).toBe(1);
+    });
+
+    it('should return the correct step for type float and value 1', () => {
+      const ret = ConfigOptionTypes.getTypeStep('float', 1);
+      expect(ret).toBe(0.1);
+    });
+
+    it('should return the correct step for type float and value 0.1', () => {
+      const ret = ConfigOptionTypes.getTypeStep('float', 0.1);
+      expect(ret).toBe(0.1);
+    });
+
+    it('should return the correct step for type float and value 0.02', () => {
+      const ret = ConfigOptionTypes.getTypeStep('float', 0.02);
+      expect(ret).toBe(0.01);
+    });
+
+    it('should return the correct step for type float and value 0.003', () => {
+      const ret = ConfigOptionTypes.getTypeStep('float', 0.003);
+      expect(ret).toBe(0.001);
+    });
+
+    it('should return the correct step for type float and value null', () => {
+      const ret = ConfigOptionTypes.getTypeStep('float', null);
+      expect(ret).toBe(0.1);
+    });
+
+    it('should return undefined for unknown type', () => {
+      const ret = ConfigOptionTypes.getTypeStep('unknown', 1);
+      expect(ret).toBeUndefined();
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/config-option/config-option.types.ts
new file mode 100644 (file)
index 0000000..cfe1b47
--- /dev/null
@@ -0,0 +1,144 @@
+import { Validators } from '@angular/forms';
+
+import * as _ from 'lodash';
+
+import { CdValidators } from '../../forms/cd-validators';
+import { ConfigFormModel } from './config-option.model';
+
+export class ConfigOptionTypes {
+  // TODO: I18N
+  private static knownTypes: Array<any> = [
+    {
+      name: 'uint',
+      inputType: 'number',
+      humanReadable: 'Unsigned integer value',
+      defaultMin: 0,
+      patternHelpText: 'The entered value needs to be an unsigned number.',
+      isNumberType: true,
+      allowsNegative: false
+    },
+    {
+      name: 'int',
+      inputType: 'number',
+      humanReadable: 'Integer value',
+      patternHelpText: 'The entered value needs to be a number.',
+      isNumberType: true,
+      allowsNegative: true
+    },
+    {
+      name: 'size',
+      inputType: 'number',
+      humanReadable: 'Unsigned integer value (>=16bit)',
+      defaultMin: 0,
+      patternHelpText: 'The entered value needs to be a unsigned number.',
+      isNumberType: true,
+      allowsNegative: false
+    },
+    {
+      name: 'secs',
+      inputType: 'number',
+      humanReadable: 'Number of seconds',
+      defaultMin: 1,
+      patternHelpText: 'The entered value needs to be a number >= 1.',
+      isNumberType: true,
+      allowsNegative: false
+    },
+    {
+      name: 'float',
+      inputType: 'number',
+      humanReadable: 'Double value',
+      patternHelpText: 'The entered value needs to be a number or decimal.',
+      isNumberType: true,
+      allowsNegative: true
+    },
+    { name: 'str', inputType: 'text', humanReadable: 'Text', isNumberType: false },
+    {
+      name: 'addr',
+      inputType: 'text',
+      humanReadable: 'IPv4 or IPv6 address',
+      patternHelpText: 'The entered value needs to be a valid IP address.',
+      isNumberType: false
+    },
+    {
+      name: 'uuid',
+      inputType: 'text',
+      humanReadable: 'UUID',
+      patternHelpText:
+        'The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8',
+      isNumberType: false
+    },
+    { name: 'bool', inputType: 'checkbox', humanReadable: 'Boolean value', isNumberType: false }
+  ];
+
+  public static getType(type: string): any {
+    const currentType = _.find(this.knownTypes, (t) => {
+      return t.name === type;
+    });
+
+    if (currentType !== undefined) {
+      return currentType;
+    }
+
+    throw new Error('Found unknown type "' + type + '" for config option.');
+  }
+
+  public static getTypeValidators(configOption: ConfigFormModel): any {
+    const typeParams = ConfigOptionTypes.getType(configOption.type);
+
+    if (typeParams.name === 'bool' || typeParams.name === 'str') {
+      return;
+    }
+
+    const typeValidators = { validators: [], patternHelpText: typeParams.patternHelpText };
+
+    if (typeParams.isNumberType) {
+      if (configOption.max && configOption.max !== '') {
+        typeValidators['max'] = configOption.max;
+        typeValidators.validators.push(Validators.max(configOption.max));
+      }
+
+      if (configOption.min && configOption.min !== '') {
+        typeValidators['min'] = configOption.min;
+        typeValidators.validators.push(Validators.min(configOption.min));
+      } else if ('defaultMin' in typeParams) {
+        typeValidators['min'] = typeParams.defaultMin;
+        typeValidators.validators.push(Validators.min(typeParams.defaultMin));
+      }
+
+      if (configOption.type === 'float') {
+        typeValidators.validators.push(CdValidators.decimalNumber());
+      } else {
+        typeValidators.validators.push(CdValidators.number(typeParams.allowsNegative));
+      }
+    } else if (configOption.type === 'addr') {
+      typeValidators.validators = [CdValidators.ip()];
+    } else if (configOption.type === 'uuid') {
+      typeValidators.validators = [CdValidators.uuid()];
+    }
+
+    return typeValidators;
+  }
+
+  public static getTypeStep(type: string, value: number): number | undefined {
+    const numberTypes = ['uint', 'int', 'size', 'secs'];
+
+    if (numberTypes.includes(type)) {
+      return 1;
+    }
+
+    if (type === 'float') {
+      if (value !== null) {
+        const stringVal = value.toString();
+        if (stringVal.indexOf('.') !== -1) {
+          // Value type float and contains decimal characters
+          const decimal = value.toString().split('.');
+          return Math.pow(10, -decimal[1].length);
+        }
+      }
+
+      return 0.1;
+    }
+
+    return undefined;
+  }
+}