diff --git a/src/pybind/mgr/dashboard/controllers/_crud.py b/src/pybind/mgr/dashboard/controllers/_crud.py index 907759f7c91..94b1a3146d7 100644 --- a/src/pybind/mgr/dashboard/controllers/_crud.py +++ b/src/pybind/mgr/dashboard/controllers/_crud.py @@ -1,5 +1,8 @@ +from enum import Enum +from functools import wraps +from inspect import isclass from typing import Any, Callable, Dict, Generator, Iterable, Iterator, List, \ - NamedTuple, Optional, get_type_hints + NamedTuple, Optional, Union, get_type_hints from ._api_router import APIRouter from ._docs import APIDoc, EndpointDoc @@ -17,6 +20,7 @@ def isnamedtuple(o): def serialize(o, expected_type=None): # pylint: disable=R1705,W1116 + print(o, expected_type) if isnamedtuple(o): hints = get_type_hints(o) return {k: serialize(v, hints[k]) for k, v in zip(o._fields, o)} @@ -28,7 +32,7 @@ def serialize(o, expected_type=None): return [serialize(i) for i in o] elif isinstance(o, (Iterator, Generator)): return [serialize(i) for i in o] - elif expected_type and issubclass(expected_type, SecretStr): + elif expected_type and isclass(expected_type) and issubclass(expected_type, SecretStr): return "***********" else: return o @@ -41,14 +45,235 @@ class TableColumn(NamedTuple): filterable: bool = True +class TableAction(NamedTuple): + name: str + permission: str + icon: str + routerLink: str # redirect to... + + class TableComponent(NamedTuple): columns: List[TableColumn] = [] columnMode: str = 'flex' toolHeader: bool = True +class Icon(Enum): + add = 'fa fa-plus' + + +class FormField(NamedTuple): + """ + The key of a FromField is then used to send the data related to that key into the + POST and PUT endpoints. It is imperative for the developer to map keys of fields and containers + to the input of the POST and PUT endpoints. + """ + name: str + key: str + field_type: Any = str + default_value: Optional[Any] = None + optional: bool = False + html_class: str = '' + label_html_class: str = 'col-form-label' + field_html_class: str = 'col-form-input' + + def get_type(self): + _type = '' + if self.field_type == str: + _type = 'string' + elif self.field_type == int: + _type = 'integer' + elif self.field_type == bool: + _type = 'boolean' + else: + raise NotImplementedError(f'Unimplemented type {self.field_type}') + return _type + + +class Container: + def __init__(self, name: str, key: str, fields: List[Union[FormField, "Container"]], + optional: bool = False, html_class: str = '', label_html_class: str = '', + field_html_class: str = ''): + self.name = name + self.key = key + self.fields = fields + self.optional = optional + self.html_class = html_class + self.label_html_class = label_html_class + self.field_html_class = field_html_class + + def layout_type(self): + raise NotImplementedError + + def _property_type(self): + raise NotImplementedError + + def to_dict(self, key=''): + # intialize the schema of this container + ui_schemas = [] + control_schema = { + 'type': self._property_type(), + 'title': self.name + } + items = None # layout items alias as it depends on the type of container + properties = None # control schema properties alias + required = None + if self._property_type() == 'array': + control_schema['items'] = { + 'type': 'object', + 'properties': {}, + 'required': [] + } + properties = control_schema['items']['properties'] + required = control_schema['items']['required'] + ui_schemas.append({ + 'type': 'array', + 'key': key, + 'htmlClass': self.html_class, + 'fieldHtmlClass': self.field_html_class, + 'labelHtmlClass': self.label_html_class, + 'items': [{ + 'type': 'div', + 'flex-direction': self.layout_type(), + 'displayFlex': True, + 'items': [] + }] + }) + items = ui_schemas[-1]['items'][0]['items'] + else: + control_schema['properties'] = {} + control_schema['required'] = [] + required = control_schema['required'] + properties = control_schema['properties'] + ui_schemas.append({ + 'type': 'section', + 'flex-direction': self.layout_type(), + 'displayFlex': True, + 'htmlClass': self.html_class, + 'fieldHtmlClass': self.field_html_class, + 'labelHtmlClass': self.label_html_class, + 'key': key, + 'items': [] + }) + if key: + items = ui_schemas[-1]['items'] + else: + items = ui_schemas + + assert items is not None + assert properties is not None + assert required is not None + + # include fields in this container's schema + for field in self.fields: + field_ui_schema = {} + properties[field.key] = {} + field_key = field.key + if key: + if self._property_type() == 'array': + field_key = key + '[].' + field.key + else: + field_key = key + '.' + field.key + + if isinstance(field, FormField): + _type = field.get_type() + properties[field.key]['type'] = _type + properties[field.key]['title'] = field.name + field_ui_schema['key'] = field_key + field_ui_schema['htmlClass'] = field.html_class + field_ui_schema['fieldHtmlClass'] = field.field_html_class + field_ui_schema['labelHtmlClass'] = field.label_html_class + items.append(field_ui_schema) + elif isinstance(field, Container): + container_schema = field.to_dict(key+'.'+field.key if key else field.key) + control_schema['properties'][field.key] = container_schema['control_schema'] + ui_schemas.extend(container_schema['ui_schema']) + if not field.optional: + required.append(field.key) + return { + 'ui_schema': ui_schemas, + 'control_schema': control_schema, + } + + +class VerticalContainer(Container): + def layout_type(self): + return 'column' + + def _property_type(self): + return 'object' + + +class HorizontalContainer(Container): + def layout_type(self): + return 'row' + + def _property_type(self): + return 'object' + + +class ArrayVerticalContainer(Container): + def layout_type(self): + return 'column' + + def _property_type(self): + return 'array' + + +class ArrayHorizontalContainer(Container): + def layout_type(self): + return 'row' + + def _property_type(self): + return 'array' + + +class Form: + def __init__(self, path, root_container, action: str = '', + footer_html_class: str = 'card-footer position-absolute pb-0 mt-3', + submit_style: str = 'btn btn-primary', cancel_style: str = ''): + self.path = path + self.action = action + self.root_container = root_container + self.footer_html_class = footer_html_class + self.submit_style = submit_style + self.cancel_style = cancel_style + + def to_dict(self): + container_schema = self.root_container.to_dict() + + # root container style + container_schema['ui_schema'].append({ + 'type': 'flex', + 'flex-flow': f'{self.root_container.layout_type()} wrap', + 'displayFlex': True, + }) + + footer = { + "type": "flex", + "htmlClass": self.footer_html_class, + "items": [ + { + 'type': 'flex', + 'flex-direction': 'row', + 'displayFlex': True, + 'htmlClass': 'd-flex justify-content-end mb-0', + 'items': [ + {"type": "cancel", "style": self.cancel_style, 'htmlClass': 'mr-2'}, + {"type": "submit", "style": self.submit_style, "title": self.action}, + ] + } + ] + } + container_schema['ui_schema'].append(footer) + return container_schema + + class CRUDMeta(NamedTuple): table: TableComponent = TableComponent() + permissions: List[str] = [] + actions: List[Dict[str, Any]] = [] + forms: List[Dict[str, Any]] = [] class CRUDCollectionMethod(NamedTuple): @@ -65,8 +290,12 @@ class CRUDEndpoint(NamedTuple): router: APIRouter doc: APIDoc set_column: Optional[Dict[str, Dict[str, str]]] = None + actions: List[TableAction] = [] + permissions: List[str] = [] + forms: List[Form] = [] meta: CRUDMeta = CRUDMeta() get_all: Optional[CRUDCollectionMethod] = None + create: Optional[CRUDCollectionMethod] = None # for testing purposes CRUDClass: Optional[RESTController] = None @@ -89,9 +318,19 @@ class CRUDEndpoint(NamedTuple): if self.get_all: @self.get_all.doc - def list(self): - return serialize(cls(**item) - for item in outer_self.get_all.func()) # type: ignore + @wraps(self.get_all.func) + def list(self, *args, **kwargs): + items = [] + for item in outer_self.get_all.func(self, *args, **kwargs): # type: ignore + items.append(serialize(cls(**item))) + return items + + if self.create: + @self.create.doc + @wraps(self.create.func) + def create(self, *args, **kwargs): + return outer_self.create.func(self, *args, **kwargs) # type: ignore + cls.CRUDClass = CRUDClass def create_meta_class(self, cls): @@ -101,6 +340,9 @@ class CRUDEndpoint(NamedTuple): class CRUDClassMetadata(RESTController): def list(self): self.update_columns() + self.generate_actions() + self.generate_forms() + self.set_permissions() return serialize(outer_self.meta) def update_columns(self): @@ -114,4 +356,20 @@ class CRUDEndpoint(NamedTuple): column.filterable) outer_self.meta.table.columns[i] = new_column + def generate_actions(self): + outer_self.meta.actions.clear() + + for action in outer_self.actions: + outer_self.meta.actions.append(action._asdict()) + + def generate_forms(self): + outer_self.meta.forms.clear() + + for form in outer_self.forms: + outer_self.meta.forms.append(form.to_dict()) + + def set_permissions(self): + if outer_self.permissions: + outer_self.meta.permissions.extend(outer_self.permissions) + cls.CRUDClassMetadata = CRUDClassMetadata diff --git a/src/pybind/mgr/dashboard/controllers/_rest_controller.py b/src/pybind/mgr/dashboard/controllers/_rest_controller.py index 03e124f9e4f..6b4afc27672 100644 --- a/src/pybind/mgr/dashboard/controllers/_rest_controller.py +++ b/src/pybind/mgr/dashboard/controllers/_rest_controller.py @@ -83,7 +83,7 @@ class RESTController(BaseController, skip_registry=True): result = super().endpoints() res_id_params = cls.infer_resource_id() - for _, func in inspect.getmembers(cls, predicate=callable): + for name, func in inspect.getmembers(cls, predicate=callable): endpoint_params = { 'no_resource_id_params': False, 'status': 200, @@ -94,10 +94,9 @@ class RESTController(BaseController, skip_registry=True): 'sec_permissions': hasattr(func, '_security_permissions'), 'permission': None, } - - if func.__name__ in cls._method_mapping: + if name in cls._method_mapping: cls._update_endpoint_params_method_map( - func, res_id_params, endpoint_params) + func, res_id_params, endpoint_params, name=name) elif hasattr(func, "__collection_method__"): cls._update_endpoint_params_collection_map(func, endpoint_params) @@ -166,8 +165,8 @@ class RESTController(BaseController, skip_registry=True): endpoint_params['permission'] = cls._permission_map[endpoint_params['method']] @classmethod - def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params): - meth = cls._method_mapping[func.__name__] # type: dict + def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params, name=None): + meth = cls._method_mapping[func.__name__ if not name else name] # type: dict if meth['resource']: if not res_id_params: diff --git a/src/pybind/mgr/dashboard/controllers/ceph_users.py b/src/pybind/mgr/dashboard/controllers/ceph_users.py index 65b8a0294b4..0c65ce1a912 100644 --- a/src/pybind/mgr/dashboard/controllers/ceph_users.py +++ b/src/pybind/mgr/dashboard/controllers/ceph_users.py @@ -1,8 +1,14 @@ -from typing import NamedTuple +import logging +from errno import EINVAL +from typing import List, NamedTuple +from ..exceptions import DashboardException from ..security import Scope -from ..services.ceph_service import CephService +from ..services.ceph_service import CephService, SendCommandError from . import APIDoc, APIRouter, CRUDCollectionMethod, CRUDEndpoint, EndpointDoc, SecretStr +from ._crud import ArrayHorizontalContainer, Form, FormField, Icon, TableAction, VerticalContainer + +logger = logging.getLogger("controllers.ceph_users") class CephUserCaps(NamedTuple): @@ -12,16 +18,83 @@ class CephUserCaps(NamedTuple): mds: str +class Cap(NamedTuple): + entity: str + cap: str + + +class CephUserEndpoints: + @staticmethod + def user_list(_): + """ + Get list of ceph users and its respective data + """ + return CephService.send_command('mon', 'auth ls')["auth_dump"] + + @staticmethod + def user_create(_, user_entity: str, capabilities: List[Cap]): + """ + Add a ceph user with its defined capabilities. + :param user_entity: Entity to change + :param capabilities: List of capabilities to add to user_entity + """ + # Caps are represented as a vector in mon auth add commands. + # Look at AuthMonitor.cc::valid_caps for reference. + caps = [] + for cap in capabilities: + caps.append(cap['entity']) + caps.append(cap['cap']) + + logger.debug("Sending command 'auth add' of entity '%s' with caps '%s'", + user_entity, str(caps)) + try: + CephService.send_command('mon', 'auth add', entity=user_entity, caps=caps) + except SendCommandError as ex: + msg = f'{ex} in command {ex.prefix}' + if ex.errno == -EINVAL: + raise DashboardException(msg, code=400) + raise DashboardException(msg, code=500) + return f"Successfully created user '{user_entity}'" + + +create_cap_container = ArrayHorizontalContainer('Capabilities', 'capabilities', + label_html_class='hidden cd-header mt-1', fields=[ + FormField('Entity', 'entity', + field_type=str, html_class='mr-3'), + FormField('Entity Capabilities', + 'cap', field_type=str) + ]) +create_container = VerticalContainer('Create User', 'create_user', + html_class='d-none', fields=[ + FormField('User entity', 'user_entity', + field_type=str), + create_cap_container, + ]) + +create_form = Form(path='/cluster/user/create', + root_container=create_container, action='Create User') + + @CRUDEndpoint( router=APIRouter('/cluster/user', Scope.CONFIG_OPT), doc=APIDoc("Get Ceph Users", "Cluster"), set_column={"caps": {"cellTemplate": "badgeDict"}}, + actions=[ + TableAction(name='create', permission='create', icon=Icon.add.value, + routerLink='/cluster/user/create') + ], + permissions=[Scope.CONFIG_OPT], + forms=[create_form], get_all=CRUDCollectionMethod( - func=lambda **_: CephService.send_command('mon', 'auth ls')["auth_dump"], + func=CephUserEndpoints.user_list, doc=EndpointDoc("Get Ceph Users") + ), + create=CRUDCollectionMethod( + func=CephUserEndpoints.user_create, + doc=EndpointDoc("Create Ceph User") ) ) class CephUser(NamedTuple): entity: str - caps: CephUserCaps + caps: List[CephUserCaps] key: SecretStr diff --git a/src/pybind/mgr/dashboard/frontend/package-lock.json b/src/pybind/mgr/dashboard/frontend/package-lock.json index 41274b28a61..1be1c806b87 100644 --- a/src/pybind/mgr/dashboard/frontend/package-lock.json +++ b/src/pybind/mgr/dashboard/frontend/package-lock.json @@ -10,6 +10,44 @@ "integrity": "sha512-20Pk2Z98fbPLkECcrZSJszKos/OgtvJJR3NcbVfgCJ6EQjDNzW2P1BKqImOz3tJ952dvO2DWEhcLhQ1Wz1e9ng==", "dev": true }, + "@ajsf/bootstrap4": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@ajsf/bootstrap4/-/bootstrap4-0.7.0.tgz", + "integrity": "sha512-wn6wIQeWknmn/t96XZgihfFq/jjr9GkV9P5dHEU+i9wQbxPNL1MS+x4tLWj9LH3Mx5RiC0Dr4gPgbkDd/bzLxg==", + "requires": { + "@ajsf/core": "~0.7.0", + "lodash-es": "~4.17.21", + "tslib": "^2.0.0" + } + }, + "@ajsf/core": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/@ajsf/core/-/core-0.7.0.tgz", + "integrity": "sha512-mysKftZAxT0bHYoia7LzbSinK7Z55wINS63zeK/rqSs9r2dF01Vxtzlx2ITViiok3TQ0UV+1OYce/piozEf4aw==", + "requires": { + "ajv": "^6.10.0", + "lodash-es": "~4.17.21", + "tslib": "^2.0.0" + }, + "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==" + } + } + }, "@ampproject/remapping": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz", @@ -11011,8 +11049,7 @@ "fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", - "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "dev": true + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, "fast-glob": { "version": "3.2.12", @@ -11046,8 +11083,7 @@ "fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", - "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", - "dev": true + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==" }, "fast-levenshtein": { "version": "2.0.6", @@ -24050,7 +24086,6 @@ "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", - "dev": true, "requires": { "punycode": "^2.1.0" }, @@ -24058,8 +24093,7 @@ "punycode": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", - "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", - "dev": true + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==" } } }, diff --git a/src/pybind/mgr/dashboard/frontend/package.json b/src/pybind/mgr/dashboard/frontend/package.json index e4b61459200..6ca0fddfda7 100644 --- a/src/pybind/mgr/dashboard/frontend/package.json +++ b/src/pybind/mgr/dashboard/frontend/package.json @@ -44,6 +44,8 @@ }, "private": true, "dependencies": { + "@ajsf/bootstrap4": "0.7.0", + "@ajsf/core": "0.7.0", "@angular/animations": "13.3.11", "@angular/common": "13.3.11", "@angular/compiler": "13.3.11", diff --git a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts index 333a8422ebe..6880a1561c1 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/app-routing.module.ts @@ -37,6 +37,7 @@ import { LoginLayoutComponent } from './core/layouts/login-layout/login-layout.c import { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component'; import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component'; import { ActionLabels, URLVerbs } from './shared/constants/app.constants'; +import { CrudFormComponent } from './shared/datatable/crud-table/crud-form/crud-form.component'; import { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component'; import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs'; import { AuthGuardService } from './shared/services/auth-guard.service'; @@ -124,6 +125,14 @@ const routes: Routes = [ resource: 'api.cluster.user@1.0' } }, + { + path: 'cluster/user/create', + component: CrudFormComponent, + data: { + breadcrumbs: 'Cluster/Users', + resource: 'api.cluster.user@1.0' + } + }, { path: 'monitor', component: MonitorComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts index 3309f47ed18..22e23d845eb 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/components/submit-button/submit-button.component.ts @@ -54,7 +54,7 @@ export class SubmitButtonComponent implements OnInit { constructor(private elRef: ElementRef) {} ngOnInit() { - this.form.statusChanges.subscribe(() => { + this.form?.statusChanges.subscribe(() => { if (_.has(this.form.errors, 'cdSubmitButton')) { this.loading = false; _.unset(this.form.errors, 'cdSubmitButton'); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.html new file mode 100644 index 00000000000..8d7b21b2382 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.html @@ -0,0 +1,20 @@ +
+
+
{{ title }}
+ +
+ + + +
+
+
diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.scss b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.scss new file mode 100644 index 00000000000..6d21e4c2d89 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.scss @@ -0,0 +1,22 @@ +@use './src/styles/vendor/variables' as vv; + +::ng-deep json-schema-form { + label.control-label.hidden { + display: none; + } + + .form-group.schema-form-submit p { + display: none; + } + + legend { + font-weight: 100 !important; + } + + .card-footer { + border: 1px solid rgba(0, 0, 0, 0.125); + left: -1px; + width: -webkit-fill-available; + width: -moz-available; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.spec.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.spec.ts new file mode 100644 index 00000000000..7a6faa7e8ac --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.spec.ts @@ -0,0 +1,42 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HttpClientTestingModule } from '@angular/common/http/testing'; +import { ToastrModule, ToastrService } from 'ngx-toastr'; +import { configureTestBed } from '~/testing/unit-test-helper'; +import { CdDatePipe } from '~/app/shared/pipes/cd-date.pipe'; +import { CrudFormComponent } from './crud-form.component'; +import { RouterTestingModule } from '@angular/router/testing'; + +describe('CrudFormComponent', () => { + let component: CrudFormComponent; + let fixture: ComponentFixture; + const toastFakeService = { + error: () => true, + info: () => true, + success: () => true + }; + + configureTestBed({ + imports: [ToastrModule.forRoot(), RouterTestingModule, HttpClientTestingModule], + providers: [ + { provide: ToastrService, useValue: toastFakeService }, + { provide: CdDatePipe, useValue: { transform: (d: any) => d } } + ] + }); + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [CrudFormComponent] + }).compileComponents(); + }); + + beforeEach(() => { + fixture = TestBed.createComponent(CrudFormComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.ts new file mode 100644 index 00000000000..4545f2ba8a8 --- /dev/null +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-form/crud-form.component.ts @@ -0,0 +1,65 @@ +import { Component, OnInit } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { DataGatewayService } from '~/app/shared/services/data-gateway.service'; +import { BackButtonComponent } from '~/app/shared/components/back-button/back-button.component'; +import { TaskWrapperService } from '~/app/shared/services/task-wrapper.service'; +import { FinishedTask } from '~/app/shared/models/finished-task'; +import { Location } from '@angular/common'; + +@Component({ + selector: 'cd-crud-form', + templateUrl: './crud-form.component.html', + styleUrls: ['./crud-form.component.scss'] +}) +export class CrudFormComponent implements OnInit { + uiSchema: any; + controlSchema: any; + data: any; + widgets: any = { + cancel: BackButtonComponent + }; + resource: string; + title: string; + + formOptions = { + defautWidgetOptions: { + validationMessages: { + required: 'This field is required' + } + } + }; + constructor( + private dataGatewayService: DataGatewayService, + private activatedRoute: ActivatedRoute, + private taskWrapper: TaskWrapperService, + private location: Location + ) {} + + ngOnInit(): void { + this.activatedRoute.data.subscribe((data: any) => { + this.resource = data.resource; + this.dataGatewayService.list(`ui-${this.resource}`).subscribe((response: any) => { + this.title = response.forms[0].control_schema.title; + this.uiSchema = response.forms[0].ui_schema; + this.controlSchema = response.forms[0].control_schema; + }); + }); + } + + submit(data: any) { + if (data) { + this.taskWrapper + .wrapTaskAroundCall({ + task: new FinishedTask('ceph-user/create', { + user_entity: data.user_entity + }), + call: this.dataGatewayService.create(this.resource, data) + }) + .subscribe({ + complete: () => { + this.location.back(); + } + }); + } + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html index e8982a92547..87a3c586b4a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/crud-table/crud-table.component.html @@ -1,10 +1,19 @@ + [data]="data$ | async" + [columns]="meta.table.columns" + [columnMode]="meta.table.columnMode" + [toolHeader]="meta.table.toolHeader"> +
+ + +
+ +
; meta$: Observable; meta: CrudMetadata; + permissions: Permissions; + permission: Permission; + selection = new CdTableSelection(); constructor( + private authStorageService: AuthStorageService, private timerService: TimerService, private dataGatewayService: DataGatewayService, private activatedRoute: ActivatedRoute - ) {} + ) { + this.permissions = this.authStorageService.getPermissions(); + } ngOnInit() { /* The following should be simplified with a wrapper that converts .data to @Input args. For example: https://medium.com/@andrewcherepovskiy/passing-route-params-into-angular-components-input-properties-fc85c34c9aca */ - this.activatedRoute.data.subscribe((data) => { + this.activatedRoute.data.subscribe((data: any) => { const resource: string = data.resource; this.dataGatewayService .list(`ui-${resource}`) - .subscribe((response) => this.processMeta(response)); + .subscribe((response: any) => this.processMeta(response)); this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource)); }); } processMeta(meta: CrudMetadata) { + const toCamelCase = (test: string) => + test + .split('-') + .reduce( + (res: string, word: string, i: number) => + i === 0 + ? word.toLowerCase() + : `${res}${word.charAt(0).toUpperCase()}${word.substr(1).toLowerCase()}`, + '' + ); + this.permission = this.permissions[toCamelCase(meta.permissions[0])]; this.meta = meta; const templates = { badgeDict: this.badgeDictTpl diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts index 9edd6624fed..39a70aff8c4 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/datatable/datatable.module.ts @@ -14,6 +14,8 @@ import { TableActionsComponent } from './table-actions/table-actions.component'; import { TableKeyValueComponent } from './table-key-value/table-key-value.component'; import { TablePaginationComponent } from './table-pagination/table-pagination.component'; import { TableComponent } from './table/table.component'; +import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4'; +import { CrudFormComponent } from './crud-table/crud-form/crud-form.component'; @NgModule({ imports: [ @@ -25,14 +27,16 @@ import { TableComponent } from './table/table.component'; NgbTooltipModule, PipesModule, ComponentsModule, - RouterModule + RouterModule, + Bootstrap4FrameworkModule ], declarations: [ TableComponent, TableKeyValueComponent, TableActionsComponent, CRUDTableComponent, - TablePaginationComponent + TablePaginationComponent, + CrudFormComponent ], exports: [ TableComponent, diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts index ba3e17621a9..fbd4979ec60 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/models/crud-table-metadata.ts @@ -1,4 +1,5 @@ import { CdTableColumn } from '~/app/shared/models/cd-table-column'; +import { CdTableAction } from './cd-table-action'; class Table { columns: CdTableColumn[]; @@ -8,4 +9,7 @@ class Table { export class CrudMetadata { table: Table; + permissions: string[]; + actions: CdTableAction[]; + forms: any; } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts index 283d37bb6f8..6617fdb314a 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/data-gateway.service.ts @@ -12,16 +12,31 @@ export class DataGatewayService { constructor(private http: HttpClient) {} list(dataPath: string): Observable { - if (this.cache[dataPath] === undefined) { + const cacheable = this.getCacheable(dataPath, 'get'); + if (this.cache[cacheable] === undefined) { const match = dataPath.match(/(?[^@]+)(?:@(?.+))?/); const url = match.groups.url.split('.').join('/'); const version = match.groups.version || '1.0'; - this.cache[dataPath] = this.http.get(url, { + this.cache[cacheable] = this.http.get(url, { headers: { Accept: `application/vnd.ceph.api.v${version}+json` } }); } - return this.cache[dataPath]; + return this.cache[cacheable]; + } + + create(dataPath: string, data: any): Observable { + const match = dataPath.match(/(?[^@]+)(?:@(?.+))?/); + const url = match.groups.url.split('.').join('/'); + const version = match.groups.version || '1.0'; + + return this.http.post(url, data, { + headers: { Accept: `application/vnd.ceph.api.v${version}+json` } + }); + } + + getCacheable(dataPath: string, method: string) { + return dataPath + method; } } diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts index 5adabe21153..595e4fc96f8 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/services/task-message.service.ts @@ -339,6 +339,9 @@ export class TaskMessageService { ), 'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) => this.service(metadata) + ), + 'ceph-users/create': this.newTaskMessage(this.commonOperations.create, (metadata) => + this.cephUser(metadata) ) }; @@ -384,6 +387,10 @@ export class TaskMessageService { return $localize`Service '${metadata.service_name}'`; } + cephUser(metadata: any) { + return $localize`Ceph User '${metadata.user_entity}'`; + } + _getTaskTitle(task: Task) { if (task.name && task.name.startsWith('progress/')) { // we don't fill the failure string because, at least for now, all diff --git a/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts index 905721fa445..ecc8bd03bfe 100644 --- a/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts +++ b/src/pybind/mgr/dashboard/frontend/src/app/shared/shared.module.ts @@ -1,5 +1,6 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; +import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4'; import { CssHelper } from '~/app/shared/classes/css-helper'; import { ComponentsModule } from './components/components.module'; @@ -11,7 +12,14 @@ import { AuthStorageService } from './services/auth-storage.service'; import { FormatterService } from './services/formatter.service'; @NgModule({ - imports: [CommonModule, PipesModule, ComponentsModule, DataTableModule, DirectivesModule], + imports: [ + CommonModule, + PipesModule, + ComponentsModule, + DataTableModule, + DirectivesModule, + Bootstrap4FrameworkModule + ], declarations: [], exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule], providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper] diff --git a/src/pybind/mgr/dashboard/frontend/src/styles.scss b/src/pybind/mgr/dashboard/frontend/src/styles.scss index 6253ec6cf46..f2c3052f800 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles.scss @@ -217,3 +217,17 @@ a.btn-light { .badge-dark { @extend .badge, .bg-dark; } + +json-schema-form { + .help-block { + @extend .invalid-feedback; + } + + .ng-touched.ng-invalid { + @extend .is-invalid; + } + + .ng-touched.ng-valid { + @extend .is-valid; + } +} diff --git a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_buttons.scss b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_buttons.scss index 5b9789b3162..dd529777a57 100644 --- a/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_buttons.scss +++ b/src/pybind/mgr/dashboard/frontend/src/styles/ceph-custom/_buttons.scss @@ -80,6 +80,10 @@ } } +.btn-default { + @extend .btn-light; +} + .btn-primary .badge { background-color: vv.$gray-200; color: vv.$primary; diff --git a/src/pybind/mgr/dashboard/openapi.yaml b/src/pybind/mgr/dashboard/openapi.yaml index 03614c1425f..d607cfb0668 100644 --- a/src/pybind/mgr/dashboard/openapi.yaml +++ b/src/pybind/mgr/dashboard/openapi.yaml @@ -2147,6 +2147,8 @@ paths: - Cluster /api/cluster/user: get: + description: "\n Get list of ceph users and its respective data\n \ + \ " parameters: [] responses: '200': @@ -2168,6 +2170,49 @@ paths: summary: Get Ceph Users tags: - Cluster + post: + description: "\n Add a ceph user with its defined capabilities.\n \ + \ :param user_entity: Entity to change\n :param capabilities: List\ + \ of capabilities to add to user_entity\n " + parameters: [] + requestBody: + content: + application/json: + schema: + properties: + capabilities: + type: string + user_entity: + type: string + required: + - user_entity + - capabilities + type: object + responses: + '201': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Resource created. + '202': + content: + application/vnd.ceph.api.v1.0+json: + type: object + description: Operation is still executing. Please check the task queue. + '400': + description: Operation exception. Please check the response body for details. + '401': + description: Unauthenticated access. Please login first. + '403': + description: Unauthorized access. Please check your permissions. + '500': + description: Unexpected error. Please check the response body for the stack + trace. + security: + - jwt: [] + summary: Create Ceph User + tags: + - Cluster /api/cluster_conf: get: parameters: [] diff --git a/src/pybind/mgr/dashboard/requirements-lint.txt b/src/pybind/mgr/dashboard/requirements-lint.txt index 1b15f46817c..d82fa1ace1d 100644 --- a/src/pybind/mgr/dashboard/requirements-lint.txt +++ b/src/pybind/mgr/dashboard/requirements-lint.txt @@ -8,3 +8,4 @@ rstcheck==3.3.1 autopep8==1.5.7 pyfakefs==4.5.0 isort==5.5.3 +jsonschema==4.16.0 diff --git a/src/pybind/mgr/dashboard/requirements-test.txt b/src/pybind/mgr/dashboard/requirements-test.txt index 625f5c358f1..4e925e8616f 100644 --- a/src/pybind/mgr/dashboard/requirements-test.txt +++ b/src/pybind/mgr/dashboard/requirements-test.txt @@ -1,3 +1,4 @@ pytest-cov pytest-instafail pyfakefs==4.5.0 +jsonschema==4.16.0 diff --git a/src/pybind/mgr/dashboard/requirements.txt b/src/pybind/mgr/dashboard/requirements.txt index 607c67426d2..8003d62a552 100644 --- a/src/pybind/mgr/dashboard/requirements.txt +++ b/src/pybind/mgr/dashboard/requirements.txt @@ -11,3 +11,4 @@ pytest pyyaml natsort setuptools +jsonpatch diff --git a/src/pybind/mgr/dashboard/tests/test_ceph_users.py b/src/pybind/mgr/dashboard/tests/test_ceph_users.py index 04a03ca355d..35029b32c65 100644 --- a/src/pybind/mgr/dashboard/tests/test_ceph_users.py +++ b/src/pybind/mgr/dashboard/tests/test_ceph_users.py @@ -1,6 +1,8 @@ import unittest.mock as mock -from ..controllers.ceph_users import CephUser +from jsonschema import validate + +from ..controllers.ceph_users import CephUser, create_form from ..tests import ControllerTestCase auth_dump_mock = {"auth_dump": [ @@ -41,3 +43,8 @@ class CephUsersControllerTestCase(ControllerTestCase): "key": "***********" } ]) + + def test_create_form(self): + form_dict = create_form.to_dict() + schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']} + validate(instance={'user_entity': 'foo', 'capabilities': []}, schema=schema['schema']) diff --git a/src/pybind/mgr/dashboard/tests/test_crud.py b/src/pybind/mgr/dashboard/tests/test_crud.py index db8aa299019..97c5728b388 100644 --- a/src/pybind/mgr/dashboard/tests/test_crud.py +++ b/src/pybind/mgr/dashboard/tests/test_crud.py @@ -4,8 +4,11 @@ import json from typing import NamedTuple import pytest +from jsonschema import validate -from ..controllers._crud import SecretStr, serialize +from ..controllers._crud import ArrayHorizontalContainer, \ + ArrayVerticalContainer, Form, FormField, HorizontalContainer, SecretStr, \ + VerticalContainer, serialize def assertObjectEquals(a, b): @@ -26,10 +29,41 @@ class NamedTupleSecretMock(NamedTuple): @pytest.mark.parametrize("inp,out", [ (["foo", "var"], ["foo", "var"]), (NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}), - (NamedTupleSecretMock(1, "test", "supposethisisakey"), {"foo": 1, "var": "test", + (NamedTupleSecretMock(1, "test", "imaginethisisakey"), {"foo": 1, "var": "test", "key": "***********"}), ((1, 2, 3), [1, 2, 3]), (set((1, 2, 3)), [1, 2, 3]), ]) def test_serialize(inp, out): assertObjectEquals(serialize(inp), out) + + +def test_schema(): + form = Form(path='/cluster/user/create', + root_container=VerticalContainer('Create user', key='create_user', fields=[ + FormField('User entity', key='user_entity', field_type=str), + ArrayHorizontalContainer('Capabilities', key='caps', fields=[ + FormField('left', field_type=str, key='left', + html_class='cd-col-form-input'), + FormField('right', key='right', field_type=str) + ]), + ArrayVerticalContainer('ah', key='ah', fields=[ + FormField('top', key='top', field_type=str, label_html_class='d-none'), + FormField('bottom', key='bottom', field_type=str) + ]), + HorizontalContainer('oh', key='oh', fields=[ + FormField('left', key='left', field_type=str, label_html_class='d-none'), + FormField('right', key='right', field_type=str) + ]), + VerticalContainer('ov', key='ov', fields=[ + FormField('top', key='top', field_type=str, label_html_class='d-none'), + FormField('bottom', key='bottom', field_type=bool) + ]), + ])) + form_dict = form.to_dict() + schema = {'schema': form_dict['control_schema'], 'layout': form_dict['ui_schema']} + validate(instance={'user_entity': 'foo', + 'caps': [{'left': 'foo', 'right': 'foo2'}], + 'ah': [{'top': 'foo', 'bottom': 'foo2'}], + 'oh': {'left': 'foo', 'right': 'foo2'}, + 'ov': {'top': 'foo', 'bottom': True}}, schema=schema['schema'])