import logging
import requests
-import time
-from .helper import DashboardTestCase, JObj, JList, JLeaf
+from .helper import DashboardTestCase, JAny, JObj, JList, JLeaf
logger = logging.getLogger(__name__)
JList(
JObj(sub_elems={
'name': JLeaf(str),
- 'enabled': JLeaf(bool)
+ 'enabled': JLeaf(bool),
+ 'options': JObj(
+ {},
+ allow_unknown=True,
+ unknown_schema=JObj({
+ 'name': str,
+ 'type': str,
+ 'level': str,
+ 'flags': int,
+ 'default_value': JAny(none=False),
+ 'min': JAny(none=False),
+ 'max': JAny(none=False),
+ 'enum_allowed': JList(str),
+ 'see_also': JList(str),
+ 'desc': str,
+ 'long_desc': str,
+ 'tags': JList(str)
+ }))
})))
module_info = self.find_object_in_list('name', 'telemetry', data)
self.assertIsNotNone(module_info)
JList(
JObj(sub_elems={
'name': JLeaf(str),
- 'enabled': JLeaf(bool)
+ 'enabled': JLeaf(bool),
+ 'options': JObj(
+ {},
+ allow_unknown=True,
+ unknown_schema=JObj({
+ 'name': str,
+ 'type': str,
+ 'level': str,
+ 'flags': int,
+ 'default_value': JAny(none=False),
+ 'min': JAny(none=False),
+ 'max': JAny(none=False),
+ 'enum_allowed': JList(str),
+ 'see_also': JList(str),
+ 'desc': str,
+ 'long_desc': str,
+ 'tags': JList(str)
+ }))
})))
module_info = self.find_object_in_list('name', 'telemetry', data)
self.assertIsNotNone(module_info)
@ApiController('/mgr/module', Scope.CONFIG_OPT)
class MgrModules(RESTController):
- managed_modules = ['telemetry']
+ ignore_modules = ['selftest']
def list(self):
"""
Get the list of managed modules.
- :return: A list of objects with the fields 'name' and 'enabled'.
+ :return: A list of objects with the fields 'enabled', 'name' and 'options'.
:rtype: list
"""
result = []
mgr_map = mgr.get('mgr_map')
for module_config in mgr_map['available_modules']:
- if self._is_module_managed(module_config['name']):
- result.append({'name': module_config['name'], 'enabled': False})
+ if module_config['name'] not in self.ignore_modules:
+ result.append({
+ 'name': module_config['name'],
+ 'enabled': False,
+ 'options': self._convert_module_options(
+ module_config['module_options'])
+ })
for name in mgr_map['modules']:
- if self._is_module_managed(name):
+ if name not in self.ignore_modules:
obj = find_object_in_list('name', name, result)
obj['enabled'] = True
return result
def enable(self, module_name):
"""
Enable the specified Ceph Mgr module.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
"""
assert self._is_module_managed(module_name)
CephService.send_command(
def disable(self, module_name):
"""
Disable the specified Ceph Mgr module.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
"""
assert self._is_module_managed(module_name)
CephService.send_command(
'mon', 'mgr module disable', module=module_name)
+ @RESTController.Resource('GET')
+ def options(self, module_name):
+ """
+ Get the module options of the specified Ceph Mgr module.
+ :param module_name: The name of the Ceph Mgr module.
+ :type module_name: str
+ :return: The module options as list of dicts.
+ :rtype: list
+ """
+ assert self._is_module_managed(module_name)
+ return self._get_module_options(module_name)
+
def _is_module_managed(self, module_name):
"""
Check if the specified Ceph Mgr module is managed by this service.
this service, otherwise ``false``.
:rtype: bool
"""
- return module_name in self.managed_modules
+ if module_name in self.ignore_modules:
+ return False
+ mgr_map = mgr.get('mgr_map')
+ for module_config in mgr_map['available_modules']:
+ if module_name == module_config['name']:
+ return True
+ return False
def _get_module_config(self, module_name):
"""
:rtype: dict
"""
options = self._get_module_config(module_name)['module_options']
- # Workaround a possible bug in the Ceph Mgr implementation. The
- # 'default_value' field is always returned as a string.
+ return self._convert_module_options(options)
+
+ def _convert_module_options(self, options):
+ # Workaround a possible bug in the Ceph Mgr implementation.
+ # Various fields (e.g. default_value, min, max) are always
+ # returned as a string.
for option in options.values():
if option['type'] == 'str':
- if option['default_value'] == 'None':
+ if option['default_value'] == 'None': # This is Python None
option['default_value'] = ''
elif option['type'] == 'bool':
- option['default_value'] = str_to_bool(option['default_value'])
+ if option['default_value'] == '':
+ option['default_value'] = False
+ else:
+ option['default_value'] = str_to_bool(
+ option['default_value'])
elif option['type'] == 'float':
- option['default_value'] = float(option['default_value'])
+ for name in ['default_value', 'min', 'max']:
+ if option[name]: # Skip empty entries
+ option[name] = float(option[name])
elif option['type'] in ['uint', 'int', 'size', 'secs']:
- option['default_value'] = int(option['default_value'])
+ for name in ['default_value', 'min', 'max']:
+ if option[name]: # Skip empty entries
+ option[name] = int(option[name])
return options
import { NgModule } from '@angular/core';
import { ActivatedRouteSnapshot, RouterModule, Routes } from '@angular/router';
+import * as _ from 'lodash';
+
import { IscsiTargetFormComponent } from './ceph/block/iscsi-target-form/iscsi-target-form.component';
import { IscsiTargetListComponent } from './ceph/block/iscsi-target-list/iscsi-target-list.component';
import { IscsiComponent } from './ceph/block/iscsi/iscsi.component';
import { CrushmapComponent } from './ceph/cluster/crushmap/crushmap.component';
import { HostsComponent } from './ceph/cluster/hosts/hosts.component';
import { LogsComponent } from './ceph/cluster/logs/logs.component';
+import { MgrModuleFormComponent } from './ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component';
+import { MgrModuleListComponent } from './ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component';
import { MonitorComponent } from './ceph/cluster/monitor/monitor.component';
import { OsdListComponent } from './ceph/cluster/osd/osd-list/osd-list.component';
import { PrometheusListComponent } from './ceph/cluster/prometheus/prometheus-list/prometheus-list.component';
import { UserFormComponent } from './core/auth/user-form/user-form.component';
import { UserListComponent } from './core/auth/user-list/user-list.component';
import { ForbiddenComponent } from './core/forbidden/forbidden.component';
-import { MgrModulesListComponent } from './core/mgr-modules/mgr-modules-list/mgr-modules-list.component';
-import { TelemetryComponent } from './core/mgr-modules/telemetry/telemetry.component';
import { NotFoundComponent } from './core/not-found/not-found.component';
import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
import { AuthGuardService } from './shared/services/auth-guard.service';
}
}
+export class StartCaseBreadcrumbsResolver extends BreadcrumbsResolver {
+ resolve(route: ActivatedRouteSnapshot) {
+ const path = route.params.name;
+ const text = _.startCase(path);
+ return [{ text: text, path: path }];
+ }
+}
+
const routes: Routes = [
// Dashboard
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
path: 'mgr-modules',
canActivate: [AuthGuardService],
canActivateChild: [AuthGuardService],
- data: { breadcrumbs: 'Cluster/Manager Modules' },
+ data: { breadcrumbs: 'Cluster/Manager modules' },
children: [
- { path: '', component: MgrModulesListComponent },
- { path: 'edit/telemetry', component: TelemetryComponent, data: { breadcrumbs: 'Telemetry' } }
+ {
+ path: '',
+ component: MgrModuleListComponent
+ },
+ {
+ path: 'edit/:name',
+ component: MgrModuleFormComponent,
+ data: {
+ breadcrumbs: StartCaseBreadcrumbsResolver
+ }
+ }
]
},
// Pools
@NgModule({
imports: [RouterModule.forRoot(routes, { useHash: true })],
exports: [RouterModule],
- providers: [PerformanceCounterBreadcrumbsResolver]
+ providers: [StartCaseBreadcrumbsResolver, PerformanceCounterBreadcrumbsResolver]
})
export class AppRoutingModule {}
import { HostDetailsComponent } from './hosts/host-details/host-details.component';
import { HostsComponent } from './hosts/hosts.component';
import { LogsComponent } from './logs/logs.component';
+import { MgrModulesModule } from './mgr-modules/mgr-modules.module';
import { MonitorComponent } from './monitor/monitor.component';
import { OsdDetailsComponent } from './osd/osd-details/osd-details.component';
import { OsdFlagsModalComponent } from './osd/osd-flags-modal/osd-flags-modal.component';
ModalModule.forRoot(),
AlertModule.forRoot(),
TooltipModule.forRoot(),
- TreeModule
+ TreeModule,
+ MgrModulesModule
],
declarations: [
HostsComponent,
--- /dev/null
+<tabset *ngIf="selection.hasSingleSelection">
+ <tab i18n-heading heading="Details">
+ <cd-table-key-value [data]="module_config">
+ </cd-table-key-value>
+ </tab>
+</tabset>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper';
+import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+import { SharedModule } from '../../../../shared/shared.module';
+import { MgrModuleDetailsComponent } from './mgr-module-details.component';
+
+describe('MgrModuleDetailsComponent', () => {
+ let component: MgrModuleDetailsComponent;
+ let fixture: ComponentFixture<MgrModuleDetailsComponent>;
+
+ configureTestBed({
+ declarations: [MgrModuleDetailsComponent],
+ imports: [HttpClientTestingModule, SharedModule, TabsModule.forRoot()],
+ providers: [i18nProviders]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MgrModuleDetailsComponent);
+ component = fixture.componentInstance;
+ component.selection = new CdTableSelection();
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
--- /dev/null
+import { Component, Input, OnChanges } from '@angular/core';
+
+import { MgrModuleService } from '../../../../shared/api/mgr-module.service';
+import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+
+@Component({
+ selector: 'cd-mgr-module-details',
+ templateUrl: './mgr-module-details.component.html',
+ styleUrls: ['./mgr-module-details.component.scss']
+})
+export class MgrModuleDetailsComponent implements OnChanges {
+ module_config: any;
+
+ @Input()
+ selection: CdTableSelection;
+
+ constructor(private mgrModuleService: MgrModuleService) {}
+
+ ngOnChanges() {
+ if (this.selection.hasSelection) {
+ const selectedItem = this.selection.first();
+ this.mgrModuleService.getConfig(selectedItem.name).subscribe((resp: any) => {
+ this.module_config = resp;
+ });
+ }
+ }
+}
--- /dev/null
+<cd-loading-panel *ngIf="loading && !error"
+ i18n>Loading configuration...</cd-loading-panel>
+<cd-error-panel *ngIf="loading && error"
+ i18n>The configuration could not be loaded.</cd-error-panel>
+
+<div class="col-sm-12 col-lg-6"
+ *ngIf="!loading && !error">
+ <form name="mgrModuleForm"
+ class="form-horizontal"
+ #frm="ngForm"
+ [formGroup]="mgrModuleForm"
+ novalidate>
+ <div class="panel panel-default">
+ <div class="panel-heading">
+ <h3 class="panel-title" i18n>Edit Manager module</h3>
+ </div>
+ <div class="panel-body">
+ <div class="form-group"
+ [ngClass]="{'has-error': mgrModuleForm.showError(moduleOption.value.name, frm)}"
+ *ngFor="let moduleOption of moduleOptions | keyvalue">
+
+ <!-- Field label -->
+ <label class="control-label col-sm-3"
+ for="{{ moduleOption.value.name }}">
+ {{ moduleOption.value.name }}
+ <cd-helper *ngIf="moduleOption.value.long_desc || moduleOption.value.desc">
+ {{ moduleOption.value.long_desc || moduleOption.value.desc | upperFirst }}
+ </cd-helper>
+ </label>
+
+ <!-- Field control -->
+ <!-- bool -->
+ <div class="col-sm-7"
+ *ngIf="moduleOption.value.type === 'bool'">
+ <div class="checkbox checkbox-primary">
+ <input id="{{ moduleOption.value.name }}"
+ type="checkbox"
+ formControlName="{{ moduleOption.value.name }}">
+ <label for="{{ moduleOption.value.name }}"></label>
+ </div>
+ </div>
+
+ <!-- addr|str|uuid -->
+ <div class="col-sm-7"
+ *ngIf="['addr', 'str', 'uuid'].includes(moduleOption.value.type)">
+ <input id="{{ moduleOption.value.name }}"
+ class="form-control"
+ type="text"
+ formControlName="{{ moduleOption.value.name }}"
+ *ngIf="moduleOption.value.enum_allowed.length === 0">
+ <select id="{{ moduleOption.value.name }}"
+ class="form-control"
+ formControlName="{{ moduleOption.value.name }}"
+ *ngIf="moduleOption.value.enum_allowed.length > 0">
+ <option *ngFor="let value of moduleOption.value.enum_allowed"
+ [ngValue]="value">
+ {{ value }}
+ </option>
+ </select>
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'invalidUuid')"
+ i18n>The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8</span>
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'pattern')"
+ i18n>The entered value needs to be a valid IP address.</span>
+ </div>
+
+ <!-- uint|int|size|secs -->
+ <div class="col-sm-7"
+ *ngIf="['uint', 'int', 'size', 'secs'].includes(moduleOption.value.type)">
+ <input id="{{ moduleOption.value.name }}"
+ class="form-control"
+ type="number"
+ formControlName="{{ moduleOption.value.name }}"
+ min="{{ moduleOption.value.min }}"
+ max="{{ moduleOption.value.max }}">
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'max')"
+ i18n>The entered value is too high! It must be lower or equal to {{ moduleOption.value.max }}.</span>
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'min')"
+ i18n>The entered value is too low! It must be greater or equal to {{ moduleOption.value.min }}.</span>
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'pattern')"
+ i18n>The entered value needs to be a number.</span>
+ </div>
+
+ <!-- float -->
+ <div class="col-sm-7"
+ *ngIf="moduleOption.value.type === 'float'">
+ <input id="{{ moduleOption.value.name }}"
+ class="form-control"
+ type="number"
+ formControlName="{{ moduleOption.value.name }}">
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'required')"
+ i18n>This field is required.</span>
+ <span class="help-block"
+ *ngIf="mgrModuleForm.showError(moduleOption.value.name, frm, 'pattern')"
+ i18n>The entered value needs to be a number or decimal.</span>
+ </div>
+
+ </div>
+ </div>
+ <div class="panel-footer">
+ <div class="button-group text-right">
+ <cd-submit-button type="button"
+ (submitAction)="onSubmit()"
+ [form]="mgrModuleForm">
+ <ng-container i18n>Update</ng-container>
+ </cd-submit-button>
+ <button type="button"
+ class="btn btn-sm btn-default"
+ routerLink="/mgr-modules"
+ i18n>Back</button>
+ </div>
+ </div>
+ </div>
+ </form>
+</div>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+
+import { configureTestBed, i18nProviders } from '../../../../../testing/unit-test-helper';
+import { SharedModule } from '../../../../shared/shared.module';
+import { MgrModuleFormComponent } from './mgr-module-form.component';
+
+describe('MgrModuleFormComponent', () => {
+ let component: MgrModuleFormComponent;
+ let fixture: ComponentFixture<MgrModuleFormComponent>;
+
+ configureTestBed({
+ declarations: [MgrModuleFormComponent],
+ imports: [
+ HttpClientTestingModule,
+ ReactiveFormsModule,
+ RouterTestingModule,
+ SharedModule,
+ ToastModule.forRoot()
+ ],
+ providers: i18nProviders
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MgrModuleFormComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ describe('getValidators', () => {
+ it('should return ip validator for type addr', () => {
+ const result = component.getValidators({ type: 'addr' });
+ expect(result.length).toBe(1);
+ });
+
+ it('should return number, required validators for types uint, int, size, secs', () => {
+ const types = ['uint', 'int', 'size', 'secs'];
+ types.forEach((type) => {
+ const result = component.getValidators({ type: type });
+ expect(result.length).toBe(2);
+ });
+ });
+
+ it('should return number, required, min validators for types uint, int, size, secs', () => {
+ const types = ['uint', 'int', 'size', 'secs'];
+ types.forEach((type) => {
+ const result = component.getValidators({ type: type, min: 2 });
+ expect(result.length).toBe(3);
+ });
+ });
+
+ it('should return number, required, min, max validators for types uint, int, size, secs', () => {
+ const types = ['uint', 'int', 'size', 'secs'];
+ types.forEach((type) => {
+ const result = component.getValidators({ type: type, min: 2, max: 5 });
+ expect(result.length).toBe(4);
+ });
+ });
+
+ it('should return required, decimalNumber validators for type float', () => {
+ const result = component.getValidators({ type: 'float' });
+ expect(result.length).toBe(2);
+ });
+
+ it('should return uuid validator for type uuid', () => {
+ const result = component.getValidators({ type: 'uuid' });
+ expect(result.length).toBe(1);
+ });
+
+ it('should return no validator for type str', () => {
+ const result = component.getValidators({ type: 'str' });
+ expect(result.length).toBe(0);
+ });
+
+ it('should return min validator for type str', () => {
+ const result = component.getValidators({ type: 'str', min: 1 });
+ expect(result.length).toBe(1);
+ });
+
+ it('should return min, max validators for type str', () => {
+ const result = component.getValidators({ type: 'str', min: 1, max: 127 });
+ expect(result.length).toBe(2);
+ });
+ });
+});
--- /dev/null
+import { Component, OnInit } from '@angular/core';
+import { ValidatorFn, Validators } from '@angular/forms';
+import { ActivatedRoute, Router } from '@angular/router';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import * as _ from 'lodash';
+import { forkJoin as observableForkJoin } from 'rxjs';
+
+import { MgrModuleService } from '../../../../shared/api/mgr-module.service';
+import { NotificationType } from '../../../../shared/enum/notification-type.enum';
+import { CdFormBuilder } from '../../../../shared/forms/cd-form-builder';
+import { CdFormGroup } from '../../../../shared/forms/cd-form-group';
+import { CdValidators } from '../../../../shared/forms/cd-validators';
+import { NotificationService } from '../../../../shared/services/notification.service';
+
+@Component({
+ selector: 'cd-mgr-module-form',
+ templateUrl: './mgr-module-form.component.html',
+ styleUrls: ['./mgr-module-form.component.scss']
+})
+export class MgrModuleFormComponent implements OnInit {
+ mgrModuleForm: CdFormGroup;
+ error = false;
+ loading = false;
+ moduleName = '';
+ moduleOptions = [];
+
+ constructor(
+ private route: ActivatedRoute,
+ private router: Router,
+ private formBuilder: CdFormBuilder,
+ private mgrModuleService: MgrModuleService,
+ private notificationService: NotificationService,
+ private i18n: I18n
+ ) {}
+
+ ngOnInit() {
+ this.route.params.subscribe(
+ (params: { name: string }) => {
+ this.moduleName = decodeURIComponent(params.name);
+ this.loading = true;
+ const observables = [];
+ observables.push(this.mgrModuleService.getOptions(this.moduleName));
+ observables.push(this.mgrModuleService.getConfig(this.moduleName));
+ observableForkJoin(observables).subscribe(
+ (resp: object) => {
+ this.loading = false;
+ this.moduleOptions = resp[0];
+ // Create the form dynamically.
+ this.createForm();
+ // Set the form field values.
+ this.mgrModuleForm.setValue(resp[1]);
+ },
+ (error) => {
+ this.error = error;
+ }
+ );
+ },
+ (error) => {
+ this.error = error;
+ }
+ );
+ }
+
+ getValidators(moduleOption): ValidatorFn[] {
+ const result = [];
+ switch (moduleOption.type) {
+ case 'addr':
+ result.push(CdValidators.ip());
+ break;
+ case 'uint':
+ case 'int':
+ case 'size':
+ case 'secs':
+ result.push(CdValidators.number());
+ result.push(Validators.required);
+ if (_.isNumber(moduleOption.min)) {
+ result.push(Validators.min(moduleOption.min));
+ }
+ if (_.isNumber(moduleOption.max)) {
+ result.push(Validators.max(moduleOption.max));
+ }
+ break;
+ case 'str':
+ if (_.isNumber(moduleOption.min)) {
+ result.push(Validators.minLength(moduleOption.min));
+ }
+ if (_.isNumber(moduleOption.max)) {
+ result.push(Validators.maxLength(moduleOption.max));
+ }
+ break;
+ case 'float':
+ result.push(Validators.required);
+ result.push(CdValidators.decimalNumber());
+ break;
+ case 'uuid':
+ result.push(CdValidators.uuid());
+ break;
+ }
+ return result;
+ }
+
+ createForm() {
+ const controlsConfig = {};
+ _.forEach(this.moduleOptions, (moduleOption) => {
+ controlsConfig[moduleOption.name] = [
+ moduleOption.default_value,
+ this.getValidators(moduleOption)
+ ];
+ });
+ this.mgrModuleForm = this.formBuilder.group(controlsConfig);
+ }
+
+ goToListView() {
+ this.router.navigate(['/mgr-modules']);
+ }
+
+ onSubmit() {
+ // Exit immediately if the form isn't dirty.
+ if (this.mgrModuleForm.pristine) {
+ this.goToListView();
+ return;
+ }
+ const config = {};
+ _.forEach(this.moduleOptions, (moduleOption) => {
+ const control = this.mgrModuleForm.get(moduleOption.name);
+ // Append the option only if the value has been modified.
+ if (control.dirty && control.valid) {
+ config[moduleOption.name] = control.value;
+ }
+ });
+ this.mgrModuleService.updateConfig(this.moduleName, config).subscribe(
+ () => {
+ this.notificationService.show(
+ NotificationType.success,
+ this.i18n('Updated options for module "{{name}}".', { name: this.moduleName })
+ );
+ this.goToListView();
+ },
+ () => {
+ // Reset the 'Submit' button.
+ this.mgrModuleForm.setErrors({ cdSubmitButton: true });
+ }
+ );
+ }
+}
--- /dev/null
+<cd-table #table
+ [autoReload]="false"
+ [data]="modules"
+ [columns]="columns"
+ columnMode="flex"
+ selectionType="single"
+ (updateSelection)="updateSelection($event)"
+ identifier="module"
+ (fetchData)="getModuleList($event)">
+ <cd-table-actions class="table-actions"
+ [permission]="permission"
+ [selection]="selection"
+ [tableActions]="tableActions">
+ </cd-table-actions>
+ <cd-mgr-module-details cdTableDetail
+ [selection]="selection">
+ </cd-mgr-module-details>
+</cd-table>
--- /dev/null
+import { HttpClientTestingModule } from '@angular/common/http/testing';
+import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+
+import { ToastModule } from 'ng2-toastr';
+import { TabsModule } from 'ngx-bootstrap/tabs';
+import { of as observableOf, throwError as observableThrowError } from 'rxjs';
+
+import {
+ configureTestBed,
+ i18nProviders,
+ PermissionHelper
+} from '../../../../../testing/unit-test-helper';
+import { MgrModuleService } from '../../../../shared/api/mgr-module.service';
+import { TableActionsComponent } from '../../../../shared/datatable/table-actions/table-actions.component';
+import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+import { NotificationService } from '../../../../shared/services/notification.service';
+import { SharedModule } from '../../../../shared/shared.module';
+import { MgrModuleDetailsComponent } from '../mgr-module-details/mgr-module-details.component';
+import { MgrModuleListComponent } from './mgr-module-list.component';
+
+describe('MgrModuleListComponent', () => {
+ let component: MgrModuleListComponent;
+ let fixture: ComponentFixture<MgrModuleListComponent>;
+ let mgrModuleService: MgrModuleService;
+ let notificationService: NotificationService;
+
+ configureTestBed({
+ declarations: [MgrModuleListComponent, MgrModuleDetailsComponent],
+ imports: [
+ RouterTestingModule,
+ SharedModule,
+ HttpClientTestingModule,
+ TabsModule.forRoot(),
+ ToastModule.forRoot()
+ ],
+ providers: [MgrModuleService, NotificationService, i18nProviders]
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(MgrModuleListComponent);
+ component = fixture.componentInstance;
+ mgrModuleService = TestBed.get(MgrModuleService);
+ notificationService = TestBed.get(NotificationService);
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ describe('show action buttons and drop down actions depending on permissions', () => {
+ let tableActions: TableActionsComponent;
+ let scenario: { fn; empty; single };
+ let permissionHelper: PermissionHelper;
+
+ const getTableActionComponent = (): TableActionsComponent => {
+ fixture.detectChanges();
+ return fixture.debugElement.query(By.directive(TableActionsComponent)).componentInstance;
+ };
+
+ beforeEach(() => {
+ permissionHelper = new PermissionHelper(component.permission, () =>
+ getTableActionComponent()
+ );
+ scenario = {
+ fn: () => tableActions.getCurrentButton().name,
+ single: 'Edit',
+ empty: 'Edit'
+ };
+ });
+
+ describe('with read and update', () => {
+ beforeEach(() => {
+ tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 0);
+ });
+
+ it('shows action button', () => permissionHelper.testScenarios(scenario));
+
+ it('shows all actions', () => {
+ expect(tableActions.tableActions.length).toBe(3);
+ expect(tableActions.tableActions).toEqual(component.tableActions);
+ });
+ });
+
+ describe('with only read', () => {
+ beforeEach(() => {
+ tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 0);
+ });
+
+ it('shows no main action', () => {
+ permissionHelper.testScenarios({
+ fn: () => tableActions.getCurrentButton(),
+ single: undefined,
+ empty: undefined
+ });
+ });
+
+ it('shows no actions', () => {
+ expect(tableActions.tableActions.length).toBe(0);
+ expect(tableActions.tableActions).toEqual([]);
+ });
+ });
+ });
+
+ describe('should update module state', () => {
+ beforeEach(() => {
+ component.selection = new CdTableSelection();
+ spyOn(notificationService, 'suspendToasties');
+ spyOn(component.blockUI, 'start');
+ spyOn(component.blockUI, 'stop');
+ spyOn(component.table, 'refreshBtn');
+ });
+
+ it('should enable module', fakeAsync(() => {
+ spyOn(mgrModuleService, 'enable').and.returnValue(observableThrowError('y'));
+ spyOn(mgrModuleService, 'list').and.returnValues(observableThrowError('z'), observableOf([]));
+ component.selection.selected.push({
+ name: 'foo',
+ enabled: false
+ });
+ component.selection.update();
+ component.updateModuleState();
+ tick(2000);
+ tick(2000);
+ expect(mgrModuleService.enable).toHaveBeenCalledWith('foo');
+ expect(mgrModuleService.list).toHaveBeenCalledTimes(2);
+ expect(notificationService.suspendToasties).toHaveBeenCalledTimes(2);
+ expect(component.blockUI.start).toHaveBeenCalled();
+ expect(component.blockUI.stop).toHaveBeenCalled();
+ expect(component.table.refreshBtn).toHaveBeenCalled();
+ }));
+
+ it('should disable module', fakeAsync(() => {
+ spyOn(mgrModuleService, 'disable').and.returnValue(observableThrowError('x'));
+ spyOn(mgrModuleService, 'list').and.returnValue(observableOf([]));
+ component.selection.selected.push({
+ name: 'bar',
+ enabled: true
+ });
+ component.selection.update();
+ component.updateModuleState();
+ tick(2000);
+ expect(mgrModuleService.disable).toHaveBeenCalledWith('bar');
+ expect(mgrModuleService.list).toHaveBeenCalledTimes(1);
+ expect(notificationService.suspendToasties).toHaveBeenCalledTimes(2);
+ expect(component.blockUI.start).toHaveBeenCalled();
+ expect(component.blockUI.stop).toHaveBeenCalled();
+ expect(component.table.refreshBtn).toHaveBeenCalled();
+ }));
+ });
+});
--- /dev/null
+import { Component, ViewChild } from '@angular/core';
+
+import { I18n } from '@ngx-translate/i18n-polyfill';
+import { BlockUI, NgBlockUI } from 'ng-block-ui';
+import { timer as observableTimer } from 'rxjs';
+
+import { MgrModuleService } from '../../../../shared/api/mgr-module.service';
+import { TableComponent } from '../../../../shared/datatable/table/table.component';
+import { CellTemplate } from '../../../../shared/enum/cell-template.enum';
+import { CdTableAction } from '../../../../shared/models/cd-table-action';
+import { CdTableColumn } from '../../../../shared/models/cd-table-column';
+import { CdTableFetchDataContext } from '../../../../shared/models/cd-table-fetch-data-context';
+import { CdTableSelection } from '../../../../shared/models/cd-table-selection';
+import { Permission } from '../../../../shared/models/permissions';
+import { AuthStorageService } from '../../../../shared/services/auth-storage.service';
+import { NotificationService } from '../../../../shared/services/notification.service';
+
+@Component({
+ selector: 'cd-mgr-module-list',
+ templateUrl: './mgr-module-list.component.html',
+ styleUrls: ['./mgr-module-list.component.scss']
+})
+export class MgrModuleListComponent {
+ @ViewChild(TableComponent)
+ table: TableComponent;
+ @BlockUI()
+ blockUI: NgBlockUI;
+
+ permission: Permission;
+ tableActions: CdTableAction[];
+ columns: CdTableColumn[] = [];
+ modules: object[] = [];
+ selection: CdTableSelection = new CdTableSelection();
+
+ constructor(
+ private authStorageService: AuthStorageService,
+ private mgrModuleService: MgrModuleService,
+ private notificationService: NotificationService,
+ private i18n: I18n
+ ) {
+ this.permission = this.authStorageService.getPermissions().configOpt;
+ this.columns = [
+ {
+ name: this.i18n('Name'),
+ prop: 'name',
+ flexGrow: 1
+ },
+ {
+ name: this.i18n('Enabled'),
+ prop: 'enabled',
+ flexGrow: 1,
+ cellClass: 'text-center',
+ cellTransformation: CellTemplate.checkIcon
+ }
+ ];
+ const getModuleUri = () =>
+ this.selection.first() && encodeURIComponent(this.selection.first().name);
+ this.tableActions = [
+ {
+ name: this.i18n('Edit'),
+ permission: 'update',
+ disable: () => {
+ if (!this.selection.hasSelection) {
+ return true;
+ }
+ // Disable the 'edit' button when the module has no options.
+ return Object.values(this.selection.first().options).length === 0;
+ },
+ routerLink: () => `/mgr-modules/edit/${getModuleUri()}`,
+ icon: 'fa-pencil'
+ },
+ {
+ name: this.i18n('Enable'),
+ permission: 'update',
+ click: () => this.updateModuleState(),
+ disable: () => this.isTableActionDisabled('enabled'),
+ icon: 'fa-play'
+ },
+ {
+ name: this.i18n('Disable'),
+ permission: 'update',
+ click: () => this.updateModuleState(),
+ disable: () => this.isTableActionDisabled('disabled'),
+ icon: 'fa-stop'
+ }
+ ];
+ }
+
+ getModuleList(context: CdTableFetchDataContext) {
+ this.mgrModuleService.list().subscribe(
+ (resp: object[]) => {
+ this.modules = resp;
+ },
+ () => {
+ context.error();
+ }
+ );
+ }
+
+ updateSelection(selection: CdTableSelection) {
+ this.selection = selection;
+ }
+
+ /**
+ * Check if the table action is disabled.
+ * @param state The expected module state, e.g. ``enabled`` or ``disabled``.
+ * @returns If the specified state is validated to true or no selection is
+ * done, then ``true`` is returned, otherwise ``false``.
+ */
+ isTableActionDisabled(state: 'enabled' | 'disabled') {
+ if (!this.selection.hasSelection) {
+ return true;
+ }
+ // Make sure the user can't modify the run state of the 'Dashboard' module.
+ // This check is only done in the UI because the REST API should still be
+ // able to do so.
+ if (this.selection.first().name === 'dashboard') {
+ return true;
+ }
+ switch (state) {
+ case 'enabled':
+ return this.selection.first().enabled;
+ case 'disabled':
+ return !this.selection.first().enabled;
+ }
+ }
+
+ /**
+ * Update the Ceph Mgr module state to enabled or disabled.
+ */
+ updateModuleState() {
+ if (!this.selection.hasSelection) {
+ return;
+ }
+
+ let $obs;
+ const fnWaitUntilReconnected = () => {
+ observableTimer(2000).subscribe(() => {
+ // Trigger an API request to check if the connection is
+ // re-established.
+ this.mgrModuleService.list().subscribe(
+ () => {
+ // Resume showing the notification toasties.
+ this.notificationService.suspendToasties(false);
+ // Unblock the whole UI.
+ this.blockUI.stop();
+ // Reload the data table content.
+ this.table.refreshBtn();
+ },
+ () => {
+ fnWaitUntilReconnected();
+ }
+ );
+ });
+ };
+
+ // Note, the Ceph Mgr is always restarted when a module
+ // is enabled/disabled.
+ const module = this.selection.first();
+ if (module.enabled) {
+ $obs = this.mgrModuleService.disable(module.name);
+ } else {
+ $obs = this.mgrModuleService.enable(module.name);
+ }
+ $obs.subscribe(
+ () => {},
+ () => {
+ // Suspend showing the notification toasties.
+ this.notificationService.suspendToasties(true);
+ // Block the whole UI to prevent user interactions until
+ // the connection to the backend is reestablished
+ this.blockUI.start(this.i18n('Reconnecting, please wait ...'));
+ fnWaitUntilReconnected();
+ }
+ );
+ }
+}
--- /dev/null
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { ReactiveFormsModule } from '@angular/forms';
+
+import { TabsModule } from 'ngx-bootstrap/tabs';
+
+import { AppRoutingModule } from '../../../app-routing.module';
+import { SharedModule } from '../../../shared/shared.module';
+import { MgrModuleDetailsComponent } from './mgr-module-details/mgr-module-details.component';
+import { MgrModuleFormComponent } from './mgr-module-form/mgr-module-form.component';
+import { MgrModuleListComponent } from './mgr-module-list/mgr-module-list.component';
+
+@NgModule({
+ imports: [
+ AppRoutingModule,
+ CommonModule,
+ ReactiveFormsModule,
+ SharedModule,
+ TabsModule.forRoot()
+ ],
+ declarations: [MgrModuleListComponent, MgrModuleFormComponent, MgrModuleDetailsComponent]
+})
+export class MgrModulesModule {}
import { AuthModule } from './auth/auth.module';
import { ForbiddenComponent } from './forbidden/forbidden.component';
-import { MgrModulesModule } from './mgr-modules/mgr-modules.module';
import { NavigationModule } from './navigation/navigation.module';
import { NotFoundComponent } from './not-found/not-found.component';
@NgModule({
- imports: [CommonModule, NavigationModule, AuthModule, MgrModulesModule],
+ imports: [CommonModule, NavigationModule, AuthModule],
exports: [NavigationModule],
declarations: [NotFoundComponent, ForbiddenComponent]
})
+++ /dev/null
-<cd-table #table
- [autoReload]="false"
- [data]="modules"
- [columns]="columns"
- columnMode="flex"
- selectionType="multi"
- (updateSelection)="updateSelection($event)"
- identifier="module"
- (fetchData)="getModuleList($event)">
- <cd-table-actions class="table-actions"
- [permission]="permission"
- [selection]="selection"
- [tableActions]="tableActions">
- </cd-table-actions>
-</cd-table>
+++ /dev/null
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { By } from '@angular/platform-browser';
-import { RouterTestingModule } from '@angular/router/testing';
-
-import { ToastModule } from 'ng2-toastr';
-
-import {
- configureTestBed,
- i18nProviders,
- PermissionHelper
-} from '../../../../testing/unit-test-helper';
-import { TableActionsComponent } from '../../../shared/datatable/table-actions/table-actions.component';
-import { SharedModule } from '../../../shared/shared.module';
-import { MgrModulesListComponent } from './mgr-modules-list.component';
-
-describe('MgrModulesListComponent', () => {
- let component: MgrModulesListComponent;
- let fixture: ComponentFixture<MgrModulesListComponent>;
-
- configureTestBed({
- declarations: [MgrModulesListComponent],
- imports: [RouterTestingModule, SharedModule, HttpClientTestingModule, ToastModule.forRoot()],
- providers: i18nProviders
- });
-
- beforeEach(() => {
- fixture = TestBed.createComponent(MgrModulesListComponent);
- component = fixture.componentInstance;
- });
-
- it('should create', () => {
- fixture.detectChanges();
- expect(component).toBeTruthy();
- });
-
- describe('show action buttons and drop down actions depending on permissions', () => {
- let tableActions: TableActionsComponent;
- let scenario: { fn; empty; single };
- let permissionHelper: PermissionHelper;
-
- const getTableActionComponent = (): TableActionsComponent => {
- fixture.detectChanges();
- return fixture.debugElement.query(By.directive(TableActionsComponent)).componentInstance;
- };
-
- beforeEach(() => {
- permissionHelper = new PermissionHelper(component.permission, () =>
- getTableActionComponent()
- );
- scenario = {
- fn: () => tableActions.getCurrentButton().name,
- single: 'Edit',
- empty: 'Edit'
- };
- });
-
- describe('with read and update', () => {
- beforeEach(() => {
- tableActions = permissionHelper.setPermissionsAndGetActions(0, 1, 0);
- });
-
- it('shows action button', () => permissionHelper.testScenarios(scenario));
-
- it('shows all actions', () => {
- expect(tableActions.tableActions.length).toBe(3);
- expect(tableActions.tableActions).toEqual(component.tableActions);
- });
- });
-
- describe('with only read', () => {
- beforeEach(() => {
- tableActions = permissionHelper.setPermissionsAndGetActions(0, 0, 0);
- });
-
- it('shows no main action', () => {
- permissionHelper.testScenarios({
- fn: () => tableActions.getCurrentButton(),
- single: undefined,
- empty: undefined
- });
- });
-
- it('shows no actions', () => {
- expect(tableActions.tableActions.length).toBe(0);
- expect(tableActions.tableActions).toEqual([]);
- });
- });
- });
-});
+++ /dev/null
-import { Component, ViewChild } from '@angular/core';
-
-import { I18n } from '@ngx-translate/i18n-polyfill';
-import { BlockUI, NgBlockUI } from 'ng-block-ui';
-import { timer as observableTimer } from 'rxjs';
-
-import { MgrModuleService } from '../../../shared/api/mgr-module.service';
-import { TableComponent } from '../../../shared/datatable/table/table.component';
-import { CellTemplate } from '../../../shared/enum/cell-template.enum';
-import { CdTableAction } from '../../../shared/models/cd-table-action';
-import { CdTableColumn } from '../../../shared/models/cd-table-column';
-import { CdTableFetchDataContext } from '../../../shared/models/cd-table-fetch-data-context';
-import { CdTableSelection } from '../../../shared/models/cd-table-selection';
-import { Permission } from '../../../shared/models/permissions';
-import { AuthStorageService } from '../../../shared/services/auth-storage.service';
-import { NotificationService } from '../../../shared/services/notification.service';
-
-@Component({
- selector: 'cd-mgr-modules-list',
- templateUrl: './mgr-modules-list.component.html',
- styleUrls: ['./mgr-modules-list.component.scss']
-})
-export class MgrModulesListComponent {
- @ViewChild(TableComponent)
- table: TableComponent;
- @BlockUI()
- blockUI: NgBlockUI;
-
- permission: Permission;
- tableActions: CdTableAction[];
- columns: CdTableColumn[] = [];
- modules: object[] = [];
- selection: CdTableSelection = new CdTableSelection();
-
- constructor(
- private authStorageService: AuthStorageService,
- private mgrModuleService: MgrModuleService,
- private notificationService: NotificationService,
- private i18n: I18n
- ) {
- this.permission = this.authStorageService.getPermissions().configOpt;
- this.columns = [
- {
- name: this.i18n('Name'),
- prop: 'name',
- flexGrow: 1
- },
- {
- name: this.i18n('Enabled'),
- prop: 'enabled',
- flexGrow: 1,
- cellTransformation: CellTemplate.checkIcon
- }
- ];
- const getModuleUri = () =>
- this.selection.first() && encodeURIComponent(this.selection.first().name);
- this.tableActions = [
- {
- permission: 'update',
- icon: 'fa-pencil',
- routerLink: () => `/mgr-modules/edit/${getModuleUri()}`,
- name: this.i18n('Edit')
- },
- {
- name: this.i18n('Enable'),
- permission: 'update',
- click: () => this.updateModuleState(),
- disable: () => this.isTableActionDisabled('enabled'),
- icon: 'fa-play'
- },
- {
- name: this.i18n('Disable'),
- permission: 'update',
- click: () => this.updateModuleState(),
- disable: () => this.isTableActionDisabled('disabled'),
- icon: 'fa-stop'
- }
- ];
- }
-
- getModuleList(context: CdTableFetchDataContext) {
- this.mgrModuleService.list().subscribe(
- (resp: object[]) => {
- this.modules = resp;
- },
- () => {
- context.error();
- }
- );
- }
-
- updateSelection(selection: CdTableSelection) {
- this.selection = selection;
- }
-
- /**
- * Check if the table action is disabled.
- * @param state The expected module state, e.g. ``enabled`` or ``disabled``.
- * @returns If the specified state is validated to true or no selection is
- * done, then ``true`` is returned, otherwise ``false``.
- */
- isTableActionDisabled(state: 'enabled' | 'disabled') {
- if (!this.selection.hasSelection) {
- return true;
- }
- switch (state) {
- case 'enabled':
- return this.selection.first().enabled;
- case 'disabled':
- return !this.selection.first().enabled;
- }
- }
-
- /**
- * Update the Ceph Mgr module state to enabled or disabled.
- */
- updateModuleState() {
- if (!this.selection.hasSelection) {
- return;
- }
-
- let $obs;
- const fnWaitUntilReconnected = () => {
- observableTimer(2000).subscribe(() => {
- // Trigger an API request to check if the connection is
- // re-established.
- this.mgrModuleService.list().subscribe(
- () => {
- // Resume showing the notification toasties.
- this.notificationService.suspendToasties(false);
- // Unblock the whole UI.
- this.blockUI.stop();
- // Reload the data table content.
- this.table.refreshBtn();
- },
- () => {
- fnWaitUntilReconnected();
- }
- );
- });
- };
-
- // Note, the Ceph Mgr is always restarted when a module
- // is enabled/disabled.
- const module = this.selection.first();
- if (module.enabled) {
- $obs = this.mgrModuleService.disable(module.name);
- } else {
- $obs = this.mgrModuleService.enable(module.name);
- }
- $obs.subscribe(
- () => {},
- () => {
- // Suspend showing the notification toasties.
- this.notificationService.suspendToasties(true);
- // Block the whole UI to prevent user interactions until
- // the connection to the backend is reestablished
- this.blockUI.start(this.i18n('Reconnecting, please wait ...'));
- fnWaitUntilReconnected();
- }
- );
- }
-}
+++ /dev/null
-import { CommonModule } from '@angular/common';
-import { NgModule } from '@angular/core';
-import { ReactiveFormsModule } from '@angular/forms';
-
-import { AppRoutingModule } from '../../app-routing.module';
-import { SharedModule } from '../../shared/shared.module';
-import { MgrModulesListComponent } from './mgr-modules-list/mgr-modules-list.component';
-import { TelemetryComponent } from './telemetry/telemetry.component';
-
-@NgModule({
- imports: [CommonModule, ReactiveFormsModule, SharedModule, AppRoutingModule],
- declarations: [TelemetryComponent, MgrModulesListComponent]
-})
-export class MgrModulesModule {}
+++ /dev/null
-<cd-loading-panel *ngIf="loading && !error"
- i18n>Loading configuration...</cd-loading-panel>
-<cd-error-panel *ngIf="loading && error"
- i18n>The configuration could not be loaded.</cd-error-panel>
-
-<div class="col-sm-12 col-lg-6"
- *ngIf="!loading && !error">
- <form name="telemetryForm"
- class="form-horizontal"
- #frm="ngForm"
- [formGroup]="telemetryForm"
- novalidate>
- <div class="panel panel-default">
- <div class="panel-heading">
- <h3 class="panel-title" i18n>Telemetry</h3>
- </div>
- <div class="panel-body">
-
- <!-- Enabled -->
- <div class="form-group">
- <div class="col-sm-offset-3 col-sm-9">
- <div class="checkbox checkbox-primary">
- <input id="enabled"
- type="checkbox"
- formControlName="enabled">
- <label for="enabled"
- i18n>Enabled</label>
- </div>
- </div>
- </div>
-
- <!-- Leaderboard -->
- <div class="form-group">
- <div class="col-sm-offset-3 col-sm-9">
- <div class="checkbox checkbox-primary">
- <input id="leaderboard"
- type="checkbox"
- formControlName="leaderboard">
- <label for="leaderboard"
- i18n>Leaderboard</label>
- </div>
- </div>
- </div>
-
- <!-- Contact -->
- <div class="form-group"
- [ngClass]="{'has-error': telemetryForm.showError('contact', frm)}">
- <label class="control-label col-sm-3"
- for="contact"
- i18n>Contact</label>
- <div class="col-sm-9">
- <input id="contact"
- class="form-control"
- type="text"
- formControlName="contact">
- <span class="help-block"
- *ngIf="telemetryForm.showError('contact', frm, 'email')"
- i18n>This is not a valid email address.</span>
- </div>
- </div>
-
- <!-- Organization -->
- <div class="form-group">
- <label class="control-label col-sm-3"
- for="organization">
- <ng-container i18n>Organization</ng-container>
- </label>
- <div class="col-sm-9">
- <input id="organization"
- class="form-control"
- type="text"
- formControlName="organization">
- </div>
- </div>
-
- <!-- Description -->
- <div class="form-group">
- <label class="control-label col-sm-3"
- for="organization">
- <ng-container i18n>Description</ng-container>
- </label>
- <div class="col-sm-9">
- <input id="description"
- class="form-control"
- type="text"
- formControlName="description">
- </div>
- </div>
-
- <!-- Proxy -->
- <div class="form-group">
- <label class="control-label col-sm-3"
- for="proxy">
- <ng-container i18n>Proxy</ng-container>
- </label>
- <div class="col-sm-9">
- <input id="proxy"
- class="form-control"
- type="text"
- formControlName="proxy">
- </div>
- </div>
-
- <!-- Interval -->
- <div class="form-group"
- [ngClass]="{'has-error': telemetryForm.showError('interval', frm)}">
- <label class="control-label col-sm-3"
- for="interval">
- <ng-container i18n>Interval</ng-container>
- </label>
- <div class="col-sm-9">
- <input id="interval"
- class="form-control"
- type="number"
- formControlName="interval">
- <span class="help-block"
- *ngIf="telemetryForm.showError('interval', frm, 'required')"
- i18n>This field is required.</span>
- <span class="help-block"
- *ngIf="telemetryForm.showError('interval', frm, 'min')"
- i18n>The entered value must be at least 24 hours.</span>
- <span class="help-block"
- *ngIf="telemetryForm.showError('interval', frm, 'pattern')"
- i18n>The entered value needs to be a number.</span>
- </div>
- </div>
-
- </div>
- <div class="panel-footer">
- <div class="button-group text-right">
- <cd-submit-button type="button"
- (submitAction)="onSubmit()"
- [form]="telemetryForm">
- <ng-container i18n>Update</ng-container>
- </cd-submit-button>
- <button i18n
- type="button"
- class="btn btn-sm btn-default"
- routerLink="/mgr-modules">Back</button>
- </div>
- </div>
- </div>
- </form>
-</div>
+++ /dev/null
-import { HttpClientTestingModule } from '@angular/common/http/testing';
-import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { ReactiveFormsModule } from '@angular/forms';
-import { RouterTestingModule } from '@angular/router/testing';
-
-import { configureTestBed } from '../../../../testing/unit-test-helper';
-import { SharedModule } from '../../../shared/shared.module';
-import { TelemetryComponent } from './telemetry.component';
-
-describe('TelemetryComponent', () => {
- let component: TelemetryComponent;
- let fixture: ComponentFixture<TelemetryComponent>;
-
- configureTestBed({
- declarations: [TelemetryComponent],
- imports: [HttpClientTestingModule, ReactiveFormsModule, RouterTestingModule, SharedModule]
- });
-
- beforeEach(() => {
- fixture = TestBed.createComponent(TelemetryComponent);
- component = fixture.componentInstance;
- fixture.detectChanges();
- });
-
- it('should create', () => {
- expect(component).toBeTruthy();
- });
-});
+++ /dev/null
-import { Component, OnInit } from '@angular/core';
-import { Validators } from '@angular/forms';
-import { Router } from '@angular/router';
-
-import { MgrModuleService } from '../../../shared/api/mgr-module.service';
-import { CdFormBuilder } from '../../../shared/forms/cd-form-builder';
-import { CdFormGroup } from '../../../shared/forms/cd-form-group';
-import { CdValidators } from '../../../shared/forms/cd-validators';
-
-@Component({
- selector: 'cd-telemetry',
- templateUrl: './telemetry.component.html',
- styleUrls: ['./telemetry.component.scss']
-})
-export class TelemetryComponent implements OnInit {
- telemetryForm: CdFormGroup;
- error = false;
- loading = false;
-
- constructor(
- private router: Router,
- private formBuilder: CdFormBuilder,
- private mgrModuleService: MgrModuleService
- ) {
- this.createForm();
- }
-
- createForm() {
- this.telemetryForm = this.formBuilder.group({
- enabled: [false],
- leaderboard: [false],
- contact: [null, [CdValidators.email]],
- organization: [null, [Validators.maxLength(256)]],
- description: [null, [Validators.maxLength(256)]],
- proxy: [null],
- interval: [72, [Validators.min(24), CdValidators.number(), Validators.required]],
- url: [null]
- });
- }
-
- ngOnInit() {
- this.loading = true;
- this.mgrModuleService.getConfig('telemetry').subscribe(
- (resp: object) => {
- this.loading = false;
- this.telemetryForm.setValue(resp);
- },
- (error) => {
- this.error = error;
- }
- );
- }
-
- goToListView() {
- this.router.navigate(['/mgr-modules']);
- }
-
- onSubmit() {
- // Exit immediately if the form isn't dirty.
- if (this.telemetryForm.pristine) {
- this.goToListView();
- }
- const config = {};
- const fieldNames = [
- 'enabled',
- 'leaderboard',
- 'contact',
- 'organization',
- 'description',
- 'proxy',
- 'interval'
- ];
- fieldNames.forEach((fieldName) => {
- config[fieldName] = this.telemetryForm.getValue(fieldName);
- });
- this.mgrModuleService.updateConfig('telemetry', config).subscribe(
- () => {
- this.goToListView();
- },
- () => {
- // Reset the 'Submit' button.
- this.telemetryForm.setErrors({ cdSubmitButton: true });
- }
- );
- }
-}
*ngIf="permissions.configOpt.read">
<a i18n
class="dropdown-item"
- routerLink="/mgr-modules">Manager Modules</a>
+ routerLink="/mgr-modules">Manager modules</a>
</li>
<li routerLinkActive="active"
class="tc_submenuitem tc_submenuitem_log"
const req = httpTesting.expectOne('api/mgr/module/bar/disable');
expect(req.request.method).toBe('POST');
});
+
+ it('should call getOptions', () => {
+ service.getOptions('foo').subscribe();
+ const req = httpTesting.expectOne('api/mgr/module/foo/options');
+ expect(req.request.method).toBe('GET');
+ });
});
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
+import { Observable } from 'rxjs';
+
import { ApiModule } from './api.module';
@Injectable({
* Get the list of Ceph Mgr modules and their state (enabled/disabled).
* @return {Observable<Object[]>}
*/
- list() {
- return this.http.get(`${this.url}`);
+ list(): Observable<Object[]> {
+ return this.http.get<Object[]>(`${this.url}`);
}
/**
* @param {string} module The name of the mgr module.
* @return {Observable<Object>}
*/
- getConfig(module: string) {
+ getConfig(module: string): Observable<Object> {
return this.http.get(`${this.url}/${module}`);
}
* @param {object} config The configuration.
* @return {Observable<Object>}
*/
- updateConfig(module: string, config: Object) {
+ updateConfig(module: string, config: Object): Observable<Object> {
return this.http.put(`${this.url}/${module}`, { config: config });
}
disable(module: string) {
return this.http.post(`${this.url}/${module}/disable`, null);
}
+
+ /**
+ * Get the Ceph Mgr module options.
+ * @param {string} module The name of the mgr module.
+ * @return {Observable<Object>}
+ */
+ getOptions(module: string): Observable<Object> {
+ return this.http.get(`${this.url}/${module}/options`);
+ }
}
<context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
<context context-type="linenumber">85</context>
</context-group>
- </trans-unit><trans-unit id="e7b2bc4b86de6e96d353f6bf2b4d793ea3a19ba0" datatype="html">
- <source>Manager Modules</source>
+ </trans-unit><trans-unit id="33da4dbeb076159510801ea764a218fd341269d2" datatype="html">
+ <source>Manager modules</source>
<context-group purpose="location">
<context context-type="sourcefile">app/core/navigation/navigation/navigation.component.html</context>
<context context-type="linenumber">92</context>
<context context-type="sourcefile">app/ceph/cluster/configuration/configuration-form/configuration-form.component.html</context>
<context context-type="linenumber">159</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">118</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/nfs/nfs-form/nfs-form.component.html</context>
<context context-type="linenumber">520</context>
<context context-type="sourcefile">app/core/auth/user-form/user-form.component.html</context>
<context context-type="linenumber">151</context>
</context-group>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">139</context>
- </context-group>
</trans-unit><trans-unit id="3fb5709e10166cbc85970cbff103db227dbeb813" datatype="html">
<source>Select a Language</source>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/block/rbd-form/rbd-form.component.html</context>
<context context-type="linenumber">181</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">79</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">100</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/cluster/osd/osd-recv-speed-modal/osd-recv-speed-modal.component.html</context>
<context context-type="linenumber">31</context>
<context context-type="sourcefile">app/core/auth/user-form/user-form.component.html</context>
<context context-type="linenumber">87</context>
</context-group>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">118</context>
- </context-group>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/block/rbd-snapshot-form/rbd-snapshot-form.component.html</context>
<context context-type="linenumber">36</context>
<context context-type="sourcefile">app/core/auth/role-form/role-form.component.html</context>
<context context-type="linenumber">46</context>
</context-group>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">80</context>
- </context-group>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/cluster/configuration/configuration-details/configuration-details.component.html</context>
<context context-type="linenumber">13</context>
<context context-type="sourcefile">app/ceph/cluster/logs/logs.component.html</context>
<context context-type="linenumber">20</context>
</context-group>
+ </trans-unit><trans-unit id="2447796ddbda942f4e2c46619cb84d69f066e568" datatype="html">
+ <source>Loading configuration...</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">2</context>
+ </context-group>
+ </trans-unit><trans-unit id="b42c0b347a841bed8859ee83de05080ee28c803b" datatype="html">
+ <source>The configuration could not be loaded.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">4</context>
+ </context-group>
+ </trans-unit><trans-unit id="31a9c2870a934b594d1390146c489f76440859ea" datatype="html">
+ <source>Edit Manager module</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">15</context>
+ </context-group>
+ </trans-unit><trans-unit id="46e09b8290d3d0afdb6baa2021395b0570606a31" datatype="html">
+ <source>The entered value is not a valid UUID, e.g.: 67dcac9f-2c03-4d6c-b7bd-1210b3a259a8</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">62</context>
+ </context-group>
+ </trans-unit><trans-unit id="7aacd038b39cfd347107d01d1dc27f5cb3e0951c" datatype="html">
+ <source>The entered value needs to be a valid IP address.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">65</context>
+ </context-group>
+ </trans-unit><trans-unit id="f19106149f4b07a0d721f9d317afed393cb7bd93" datatype="html">
+ <source>The entered value is too high! It must be lower or equal to <x id="INTERPOLATION" equiv-text="{{ moduleOption.value.max }}"/>.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">82</context>
+ </context-group>
+ </trans-unit><trans-unit id="6d33c40ef9a6c3bf0888df831b25e41e65f9d15b" datatype="html">
+ <source>The entered value is too low! It must be greater or equal to <x id="INTERPOLATION" equiv-text="{{ moduleOption.value.min }}"/>.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">85</context>
+ </context-group>
+ </trans-unit><trans-unit id="eae7086660cf1e38c7194a2c49ff52cc656f90f5" datatype="html">
+ <source>The entered value needs to be a number.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">88</context>
+ </context-group>
+ </trans-unit><trans-unit id="a73376e04b4fb3a20734c8c39743fba32e6676ce" datatype="html">
+ <source>The entered value needs to be a number or decimal.</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">103</context>
+ </context-group>
+ </trans-unit><trans-unit id="047f50bc5b5d17b5bec0196355953e1a5c590ddb" datatype="html">
+ <source>Update</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html</context>
+ <context context-type="linenumber">113</context>
+ </context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html</context>
+ <context context-type="linenumber">41</context>
+ </context-group>
</trans-unit><trans-unit id="81b97b8ea996ad1e4f9fca8415021850214884b1" datatype="html">
<source>Status</source>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/cluster/configuration/configuration-details/configuration-details.component.html</context>
<context context-type="linenumber">3</context>
</context-group>
+ <context-group purpose="location">
+ <context context-type="sourcefile">app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html</context>
+ <context context-type="linenumber">2</context>
+ </context-group>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/block/rbd-details/rbd-details.component.html</context>
<context context-type="linenumber">8</context>
<context context-type="sourcefile">app/ceph/rgw/rgw-user-form/rgw-user-form.component.html</context>
<context context-type="linenumber">79</context>
</context-group>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">58</context>
- </context-group>
</trans-unit><trans-unit id="ca271adf154956b8fcb28f4f50a37acb3057ff7c" datatype="html">
<source>The chosen email address is already in use.</source>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/rgw/rgw-user-form/rgw-user-form.component.html</context>
<context context-type="linenumber">506</context>
</context-group>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">27</context>
- </context-group>
<context-group purpose="location">
<context context-type="sourcefile">app/ceph/rgw/rgw-bucket-details/rgw-bucket-details.component.html</context>
<context context-type="linenumber">71</context>
<context context-type="sourcefile">app/core/forbidden/forbidden.component.html</context>
<context context-type="linenumber">7</context>
</context-group>
- </trans-unit><trans-unit id="2447796ddbda942f4e2c46619cb84d69f066e568" datatype="html">
- <source>Loading configuration...</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">2</context>
- </context-group>
- </trans-unit><trans-unit id="b42c0b347a841bed8859ee83de05080ee28c803b" datatype="html">
- <source>The configuration could not be loaded.</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">4</context>
- </context-group>
- </trans-unit><trans-unit id="30b9c9863aac7b725b5c028b67c217981474aab4" datatype="html">
- <source>Telemetry</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">15</context>
- </context-group>
- </trans-unit><trans-unit id="49c5da085adc2bdeef21fe3d6739ac7a9403668a" datatype="html">
- <source>Leaderboard</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">40</context>
- </context-group>
- </trans-unit><trans-unit id="34746fb1c7f3d2194d99652bdff89e6e14c9c4f4" datatype="html">
- <source>Contact</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">50</context>
- </context-group>
- </trans-unit><trans-unit id="f3a58c8a81b9ffda26a154eae25a422c9f7de37b" datatype="html">
- <source>Organization</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">66</context>
- </context-group>
- </trans-unit><trans-unit id="a92e437fac14b4010c4515b31c55a311d026a57f" datatype="html">
- <source>Proxy</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">94</context>
- </context-group>
- </trans-unit><trans-unit id="7d49310535abb3792d8c9c991dd0d990d73d29af" datatype="html">
- <source>Interval</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">109</context>
- </context-group>
- </trans-unit><trans-unit id="7717ce40086b2c8d87ddfb180acf35b5f7dca14b" datatype="html">
- <source>The entered value must be at least 24 hours.</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">121</context>
- </context-group>
- </trans-unit><trans-unit id="eae7086660cf1e38c7194a2c49ff52cc656f90f5" datatype="html">
- <source>The entered value needs to be a number.</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">124</context>
- </context-group>
- </trans-unit><trans-unit id="047f50bc5b5d17b5bec0196355953e1a5c590ddb" datatype="html">
- <source>Update</source>
- <context-group purpose="location">
- <context context-type="sourcefile">app/core/mgr-modules/telemetry/telemetry.component.html</context>
- <context context-type="linenumber">134</context>
- </context-group>
- <context-group purpose="location">
- <context context-type="sourcefile">app/ceph/block/mirroring/pool-edit-mode-modal/pool-edit-mode-modal.component.html</context>
- <context context-type="linenumber">41</context>
- </context-group>
</trans-unit><trans-unit id="e3c028c58f92453d46f09b5adf95b2f013ee0300" datatype="html">
<source>Sorry, we could not find what you were looking for</source>
<context-group purpose="location">
<context context-type="linenumber">1</context>
</context-group>
</trans-unit>
+ <trans-unit id="1a6e6ad05508130b7a99ce5b15daf3227c2a930c" datatype="html">
+ <source>Updated options for module "<x id="INTERPOLATION" equiv-text="{{name}}"/>".</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts</context>
+ <context context-type="linenumber">1</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="ac6c9212896d39b23811ed2dadab7d07336ec559" datatype="html">
+ <source>Enable</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts</context>
+ <context context-type="linenumber">1</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6766a926d80bb7763785621098dae459d6226429" datatype="html">
+ <source>Disable</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts</context>
+ <context context-type="linenumber">1</context>
+ </context-group>
+ </trans-unit>
+ <trans-unit id="6a0b3657745dd7a2f2162f1cc790bf9004d0845d" datatype="html">
+ <source>Reconnecting, please wait ...</source>
+ <context-group purpose="location">
+ <context context-type="sourcefile">src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts</context>
+ <context context-type="linenumber">1</context>
+ </context-group>
+ </trans-unit>
<trans-unit id="7099a8878af7a0a26fe5663c4f46cfe87142b75d" datatype="html">
<source>Public Address</source>
<context-group purpose="location">
<context context-type="linenumber">1</context>
</context-group>
</trans-unit>
- <trans-unit id="ac6c9212896d39b23811ed2dadab7d07336ec559" datatype="html">
- <source>Enable</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts</context>
- <context context-type="linenumber">1</context>
- </context-group>
- </trans-unit>
- <trans-unit id="6766a926d80bb7763785621098dae459d6226429" datatype="html">
- <source>Disable</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts</context>
- <context context-type="linenumber">1</context>
- </context-group>
- </trans-unit>
- <trans-unit id="6a0b3657745dd7a2f2162f1cc790bf9004d0845d" datatype="html">
- <source>Reconnecting, please wait ...</source>
- <context-group purpose="location">
- <context context-type="sourcefile">src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts</context>
- <context context-type="linenumber">1</context>
- </context-group>
- </trans-unit>
<trans-unit id="1d7fbcc3d5efc946ffbcf86fed04c4e20dda20fb" datatype="html">
<source>Each object is split in data-chunks parts, each stored on a different OSD.</source>
<context-group purpose="location">
{
'name': 'interval',
'type': 'int',
- 'default': 72
+ 'default': 72,
+ 'min': 24
}
]