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 @@ +