]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Configure all mgr modules in UI
authorVolker Theile <vtheile@suse.com>
Tue, 22 Jan 2019 09:31:14 +0000 (10:31 +0100)
committerVolker Theile <vtheile@suse.com>
Tue, 26 Feb 2019 13:12:07 +0000 (14:12 +0100)
Fixes: https://tracker.ceph.com/issues/37934
Signed-off-by: Volker Theile <vtheile@suse.com>
32 files changed:
qa/tasks/mgr/dashboard/test_mgr_module.py
src/pybind/mgr/dashboard/controllers/mgr_modules.py
src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/cluster.module.ts
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.html [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.scss [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-modules.module.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/src/app/core/core.module.ts
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules.module.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.html [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.scss [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.spec.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.ts [deleted file]
src/pybind/mgr/dashboard/frontend/src/app/core/navigation/navigation/navigation.component.html
src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.spec.ts
src/pybind/mgr/dashboard/frontend/src/app/shared/api/mgr-module.service.ts
src/pybind/mgr/dashboard/frontend/src/locale/messages.xlf
src/pybind/mgr/telemetry/module.py

index 8348476fe41d393cb9c8effb1ca2674f82af6548..9e7d760189ca56acba543a21d5a278e511b6fa74 100644 (file)
@@ -3,9 +3,8 @@ from __future__ import absolute_import
 
 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__)
 
@@ -45,7 +44,24 @@ class MgrModuleTest(MgrModuleTestCase):
             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)
@@ -61,7 +77,24 @@ class MgrModuleTest(MgrModuleTestCase):
             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)
index 26eeebd9fc24ff83f8269f51ac6c2df7d6dbd89e..358ae8be56abe1045441c16f97e4626843ab11ad 100644 (file)
@@ -11,21 +11,26 @@ from ..tools import find_object_in_list, str_to_bool
 
 @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
@@ -66,6 +71,8 @@ class MgrModules(RESTController):
     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(
@@ -76,11 +83,25 @@ class MgrModules(RESTController):
     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.
@@ -90,7 +111,13 @@ class MgrModules(RESTController):
             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):
         """
@@ -114,16 +141,28 @@ class MgrModules(RESTController):
         :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
index c870c4cb25457cd0aab75103817bbcc67dc51bcb..89b4e7dd1956dcd467274521718ce137f09d7c47 100644 (file)
@@ -1,6 +1,8 @@
 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';
@@ -13,6 +15,8 @@ import { ConfigurationComponent } from './ceph/cluster/configuration/configurati
 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';
@@ -36,8 +40,6 @@ import { SsoNotFoundComponent } from './core/auth/sso/sso-not-found/sso-not-foun
 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';
@@ -66,6 +68,14 @@ export class PerformanceCounterBreadcrumbsResolver extends BreadcrumbsResolver {
   }
 }
 
+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' },
@@ -138,10 +148,19 @@ const routes: Routes = [
     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
@@ -349,6 +368,6 @@ const routes: Routes = [
 @NgModule({
   imports: [RouterModule.forRoot(routes, { useHash: true })],
   exports: [RouterModule],
-  providers: [PerformanceCounterBreadcrumbsResolver]
+  providers: [StartCaseBreadcrumbsResolver, PerformanceCounterBreadcrumbsResolver]
 })
 export class AppRoutingModule {}
index a8aeb7d855f7d82d37701e22d701737d56189271..a84f0544c49e7b277b1b5f80d53a38da6dbba122 100644 (file)
@@ -19,6 +19,7 @@ import { CrushmapComponent } from './crushmap/crushmap.component';
 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';
@@ -49,7 +50,8 @@ import { PrometheusListComponent } from './prometheus/prometheus-list/prometheus
     ModalModule.forRoot(),
     AlertModule.forRoot(),
     TooltipModule.forRoot(),
-    TreeModule
+    TreeModule,
+    MgrModulesModule
   ],
   declarations: [
     HostsComponent,
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.html
new file mode 100644 (file)
index 0000000..f1a5489
--- /dev/null
@@ -0,0 +1,6 @@
+<tabset *ngIf="selection.hasSingleSelection">
+  <tab i18n-heading heading="Details">
+    <cd-table-key-value [data]="module_config">
+    </cd-table-key-value>
+  </tab>
+</tabset>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.spec.ts
new file mode 100644 (file)
index 0000000..2366834
--- /dev/null
@@ -0,0 +1,31 @@
+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();
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-details/mgr-module-details.component.ts
new file mode 100644 (file)
index 0000000..dd16677
--- /dev/null
@@ -0,0 +1,27 @@
+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;
+      });
+    }
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.html
new file mode 100644 (file)
index 0000000..1854e37
--- /dev/null
@@ -0,0 +1,123 @@
+<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.spec.ts
new file mode 100644 (file)
index 0000000..1021482
--- /dev/null
@@ -0,0 +1,93 @@
+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);
+    });
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-form/mgr-module-form.component.ts
new file mode 100644 (file)
index 0000000..ae525d5
--- /dev/null
@@ -0,0 +1,146 @@
+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 });
+      }
+    );
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.html
new file mode 100644 (file)
index 0000000..967ae66
--- /dev/null
@@ -0,0 +1,18 @@
+<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.scss
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.spec.ts
new file mode 100644 (file)
index 0000000..93b1498
--- /dev/null
@@ -0,0 +1,153 @@
+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();
+    }));
+  });
+});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-module-list/mgr-module-list.component.ts
new file mode 100644 (file)
index 0000000..50a13fd
--- /dev/null
@@ -0,0 +1,177 @@
+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();
+      }
+    );
+  }
+}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-modules.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/ceph/cluster/mgr-modules/mgr-modules.module.ts
new file mode 100644 (file)
index 0000000..1abf09a
--- /dev/null
@@ -0,0 +1,23 @@
+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 {}
index 5d61b6909a1787c4d1c6bcf4a27898e3852b55e1..0190e52e22167e14bec33f8898c8d495c2e13155 100644 (file)
@@ -3,12 +3,11 @@ import { NgModule } from '@angular/core';
 
 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]
 })
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.html
deleted file mode 100644 (file)
index d0efeae..0000000
+++ /dev/null
@@ -1,15 +0,0 @@
-<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.spec.ts
deleted file mode 100644 (file)
index 3739b8f..0000000
+++ /dev/null
@@ -1,90 +0,0 @@
-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([]);
-      });
-    });
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules-list/mgr-modules-list.component.ts
deleted file mode 100644 (file)
index 94c7cf4..0000000
+++ /dev/null
@@ -1,163 +0,0 @@
-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();
-      }
-    );
-  }
-}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/mgr-modules.module.ts
deleted file mode 100644 (file)
index e101fef..0000000
+++ /dev/null
@@ -1,14 +0,0 @@
-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 {}
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.html b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.html
deleted file mode 100644 (file)
index 58e4c07..0000000
+++ /dev/null
@@ -1,144 +0,0 @@
-<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>
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.scss
deleted file mode 100644 (file)
index e69de29..0000000
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.spec.ts
deleted file mode 100644 (file)
index 63f7766..0000000
+++ /dev/null
@@ -1,28 +0,0 @@
-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();
-  });
-});
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/core/mgr-modules/telemetry/telemetry.component.ts
deleted file mode 100644 (file)
index 0245d2d..0000000
+++ /dev/null
@@ -1,86 +0,0 @@
-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 });
-      }
-    );
-  }
-}
index 60eeb1e6b14628407e7138e0fbd38e10c4cd660e..67b30c719f92ca670bff2470a5b53e7f66d1ecc6 100644 (file)
@@ -89,7 +89,7 @@
               *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"
index f28591508a9954374f44f72685108cb8292037b9..d996c5659374e82f9468efcba7c6a58b8c06a173 100644 (file)
@@ -57,4 +57,10 @@ describe('MgrModuleService', () => {
     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');
+  });
 });
index f5d68ca071c74ca8ee0d9ac219c21e00398dfe1d..782ee737b76e6edeef2e886fd6a10348adedf3a2 100644 (file)
@@ -1,6 +1,8 @@
 import { HttpClient } from '@angular/common/http';
 import { Injectable } from '@angular/core';
 
+import { Observable } from 'rxjs';
+
 import { ApiModule } from './api.module';
 
 @Injectable({
@@ -15,8 +17,8 @@ export class MgrModuleService {
    * 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}`);
   }
 
   /**
@@ -24,7 +26,7 @@ export class MgrModuleService {
    * @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}`);
   }
 
@@ -34,7 +36,7 @@ export class MgrModuleService {
    * @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 });
   }
 
@@ -53,4 +55,13 @@ export class MgrModuleService {
   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`);
+  }
 }
index 61978fea0a2cee6fd54ba9c7f3888522613ba5c2..e51e45da4686f3cccea221f2a43c2386924f762d 100644 (file)
@@ -74,8 +74,8 @@
           <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 &quot;<x id="INTERPOLATION" equiv-text="{{name}}"/>&quot;.</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">
index 283f5ee3cf45c8766c32cf359376bf9ce94b00d3..9d54298eadd90fc7fb9ce81eae4dfae495801d96 100644 (file)
@@ -70,7 +70,8 @@ class Module(MgrModule):
         {
             'name': 'interval',
             'type': 'int',
-            'default': 72
+            'default': 72,
+            'min': 24
         }
     ]