mirror of
https://github.com/ceph/ceph
synced 2025-04-09 11:12:50 +00:00
mgr/dashboard: create authx users
Signed-off-by: Pere Diaz Bou <pdiazbou@redhat.com> Signed-off-by: Nizamudeen A <nia@redhat.com> Co-authored-by: Nizamudeen A <nia@redhat.com>
This commit is contained in:
parent
cb17f28627
commit
10f17bd9eb
@ -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, \
|
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 ._api_router import APIRouter
|
||||||
from ._docs import APIDoc, EndpointDoc
|
from ._docs import APIDoc, EndpointDoc
|
||||||
@ -17,6 +20,7 @@ def isnamedtuple(o):
|
|||||||
|
|
||||||
def serialize(o, expected_type=None):
|
def serialize(o, expected_type=None):
|
||||||
# pylint: disable=R1705,W1116
|
# pylint: disable=R1705,W1116
|
||||||
|
print(o, expected_type)
|
||||||
if isnamedtuple(o):
|
if isnamedtuple(o):
|
||||||
hints = get_type_hints(o)
|
hints = get_type_hints(o)
|
||||||
return {k: serialize(v, hints[k]) for k, v in zip(o._fields, 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]
|
return [serialize(i) for i in o]
|
||||||
elif isinstance(o, (Iterator, Generator)):
|
elif isinstance(o, (Iterator, Generator)):
|
||||||
return [serialize(i) for i in o]
|
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 "***********"
|
return "***********"
|
||||||
else:
|
else:
|
||||||
return o
|
return o
|
||||||
@ -41,14 +45,235 @@ class TableColumn(NamedTuple):
|
|||||||
filterable: bool = True
|
filterable: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class TableAction(NamedTuple):
|
||||||
|
name: str
|
||||||
|
permission: str
|
||||||
|
icon: str
|
||||||
|
routerLink: str # redirect to...
|
||||||
|
|
||||||
|
|
||||||
class TableComponent(NamedTuple):
|
class TableComponent(NamedTuple):
|
||||||
columns: List[TableColumn] = []
|
columns: List[TableColumn] = []
|
||||||
columnMode: str = 'flex'
|
columnMode: str = 'flex'
|
||||||
toolHeader: bool = True
|
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):
|
class CRUDMeta(NamedTuple):
|
||||||
table: TableComponent = TableComponent()
|
table: TableComponent = TableComponent()
|
||||||
|
permissions: List[str] = []
|
||||||
|
actions: List[Dict[str, Any]] = []
|
||||||
|
forms: List[Dict[str, Any]] = []
|
||||||
|
|
||||||
|
|
||||||
class CRUDCollectionMethod(NamedTuple):
|
class CRUDCollectionMethod(NamedTuple):
|
||||||
@ -65,8 +290,12 @@ class CRUDEndpoint(NamedTuple):
|
|||||||
router: APIRouter
|
router: APIRouter
|
||||||
doc: APIDoc
|
doc: APIDoc
|
||||||
set_column: Optional[Dict[str, Dict[str, str]]] = None
|
set_column: Optional[Dict[str, Dict[str, str]]] = None
|
||||||
|
actions: List[TableAction] = []
|
||||||
|
permissions: List[str] = []
|
||||||
|
forms: List[Form] = []
|
||||||
meta: CRUDMeta = CRUDMeta()
|
meta: CRUDMeta = CRUDMeta()
|
||||||
get_all: Optional[CRUDCollectionMethod] = None
|
get_all: Optional[CRUDCollectionMethod] = None
|
||||||
|
create: Optional[CRUDCollectionMethod] = None
|
||||||
|
|
||||||
# for testing purposes
|
# for testing purposes
|
||||||
CRUDClass: Optional[RESTController] = None
|
CRUDClass: Optional[RESTController] = None
|
||||||
@ -89,9 +318,19 @@ class CRUDEndpoint(NamedTuple):
|
|||||||
|
|
||||||
if self.get_all:
|
if self.get_all:
|
||||||
@self.get_all.doc
|
@self.get_all.doc
|
||||||
def list(self):
|
@wraps(self.get_all.func)
|
||||||
return serialize(cls(**item)
|
def list(self, *args, **kwargs):
|
||||||
for item in outer_self.get_all.func()) # type: ignore
|
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
|
cls.CRUDClass = CRUDClass
|
||||||
|
|
||||||
def create_meta_class(self, cls):
|
def create_meta_class(self, cls):
|
||||||
@ -101,6 +340,9 @@ class CRUDEndpoint(NamedTuple):
|
|||||||
class CRUDClassMetadata(RESTController):
|
class CRUDClassMetadata(RESTController):
|
||||||
def list(self):
|
def list(self):
|
||||||
self.update_columns()
|
self.update_columns()
|
||||||
|
self.generate_actions()
|
||||||
|
self.generate_forms()
|
||||||
|
self.set_permissions()
|
||||||
return serialize(outer_self.meta)
|
return serialize(outer_self.meta)
|
||||||
|
|
||||||
def update_columns(self):
|
def update_columns(self):
|
||||||
@ -114,4 +356,20 @@ class CRUDEndpoint(NamedTuple):
|
|||||||
column.filterable)
|
column.filterable)
|
||||||
outer_self.meta.table.columns[i] = new_column
|
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
|
cls.CRUDClassMetadata = CRUDClassMetadata
|
||||||
|
@ -83,7 +83,7 @@ class RESTController(BaseController, skip_registry=True):
|
|||||||
result = super().endpoints()
|
result = super().endpoints()
|
||||||
res_id_params = cls.infer_resource_id()
|
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 = {
|
endpoint_params = {
|
||||||
'no_resource_id_params': False,
|
'no_resource_id_params': False,
|
||||||
'status': 200,
|
'status': 200,
|
||||||
@ -94,10 +94,9 @@ class RESTController(BaseController, skip_registry=True):
|
|||||||
'sec_permissions': hasattr(func, '_security_permissions'),
|
'sec_permissions': hasattr(func, '_security_permissions'),
|
||||||
'permission': None,
|
'permission': None,
|
||||||
}
|
}
|
||||||
|
if name in cls._method_mapping:
|
||||||
if func.__name__ in cls._method_mapping:
|
|
||||||
cls._update_endpoint_params_method_map(
|
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__"):
|
elif hasattr(func, "__collection_method__"):
|
||||||
cls._update_endpoint_params_collection_map(func, endpoint_params)
|
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']]
|
endpoint_params['permission'] = cls._permission_map[endpoint_params['method']]
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params):
|
def _update_endpoint_params_method_map(cls, func, res_id_params, endpoint_params, name=None):
|
||||||
meth = cls._method_mapping[func.__name__] # type: dict
|
meth = cls._method_mapping[func.__name__ if not name else name] # type: dict
|
||||||
|
|
||||||
if meth['resource']:
|
if meth['resource']:
|
||||||
if not res_id_params:
|
if not res_id_params:
|
||||||
|
@ -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 ..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 . 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):
|
class CephUserCaps(NamedTuple):
|
||||||
@ -12,16 +18,83 @@ class CephUserCaps(NamedTuple):
|
|||||||
mds: str
|
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(
|
@CRUDEndpoint(
|
||||||
router=APIRouter('/cluster/user', Scope.CONFIG_OPT),
|
router=APIRouter('/cluster/user', Scope.CONFIG_OPT),
|
||||||
doc=APIDoc("Get Ceph Users", "Cluster"),
|
doc=APIDoc("Get Ceph Users", "Cluster"),
|
||||||
set_column={"caps": {"cellTemplate": "badgeDict"}},
|
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(
|
get_all=CRUDCollectionMethod(
|
||||||
func=lambda **_: CephService.send_command('mon', 'auth ls')["auth_dump"],
|
func=CephUserEndpoints.user_list,
|
||||||
doc=EndpointDoc("Get Ceph Users")
|
doc=EndpointDoc("Get Ceph Users")
|
||||||
|
),
|
||||||
|
create=CRUDCollectionMethod(
|
||||||
|
func=CephUserEndpoints.user_create,
|
||||||
|
doc=EndpointDoc("Create Ceph User")
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
class CephUser(NamedTuple):
|
class CephUser(NamedTuple):
|
||||||
entity: str
|
entity: str
|
||||||
caps: CephUserCaps
|
caps: List[CephUserCaps]
|
||||||
key: SecretStr
|
key: SecretStr
|
||||||
|
48
src/pybind/mgr/dashboard/frontend/package-lock.json
generated
48
src/pybind/mgr/dashboard/frontend/package-lock.json
generated
@ -10,6 +10,44 @@
|
|||||||
"integrity": "sha512-20Pk2Z98fbPLkECcrZSJszKos/OgtvJJR3NcbVfgCJ6EQjDNzW2P1BKqImOz3tJ952dvO2DWEhcLhQ1Wz1e9ng==",
|
"integrity": "sha512-20Pk2Z98fbPLkECcrZSJszKos/OgtvJJR3NcbVfgCJ6EQjDNzW2P1BKqImOz3tJ952dvO2DWEhcLhQ1Wz1e9ng==",
|
||||||
"dev": true
|
"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": {
|
"@ampproject/remapping": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.2.0.tgz",
|
||||||
@ -11011,8 +11049,7 @@
|
|||||||
"fast-deep-equal": {
|
"fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"fast-glob": {
|
"fast-glob": {
|
||||||
"version": "3.2.12",
|
"version": "3.2.12",
|
||||||
@ -11046,8 +11083,7 @@
|
|||||||
"fast-json-stable-stringify": {
|
"fast-json-stable-stringify": {
|
||||||
"version": "2.1.0",
|
"version": "2.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
|
||||||
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
|
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"fast-levenshtein": {
|
"fast-levenshtein": {
|
||||||
"version": "2.0.6",
|
"version": "2.0.6",
|
||||||
@ -24050,7 +24086,6 @@
|
|||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
"integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==",
|
||||||
"dev": true,
|
|
||||||
"requires": {
|
"requires": {
|
||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
},
|
},
|
||||||
@ -24058,8 +24093,7 @@
|
|||||||
"punycode": {
|
"punycode": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz",
|
||||||
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==",
|
"integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A=="
|
||||||
"dev": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -44,6 +44,8 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ajsf/bootstrap4": "0.7.0",
|
||||||
|
"@ajsf/core": "0.7.0",
|
||||||
"@angular/animations": "13.3.11",
|
"@angular/animations": "13.3.11",
|
||||||
"@angular/common": "13.3.11",
|
"@angular/common": "13.3.11",
|
||||||
"@angular/compiler": "13.3.11",
|
"@angular/compiler": "13.3.11",
|
||||||
|
@ -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 { WorkbenchLayoutComponent } from './core/layouts/workbench-layout/workbench-layout.component';
|
||||||
import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
|
import { ApiDocsComponent } from './core/navigation/api-docs/api-docs.component';
|
||||||
import { ActionLabels, URLVerbs } from './shared/constants/app.constants';
|
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 { CRUDTableComponent } from './shared/datatable/crud-table/crud-table.component';
|
||||||
import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
|
import { BreadcrumbsResolver, IBreadcrumb } from './shared/models/breadcrumbs';
|
||||||
import { AuthGuardService } from './shared/services/auth-guard.service';
|
import { AuthGuardService } from './shared/services/auth-guard.service';
|
||||||
@ -124,6 +125,14 @@ const routes: Routes = [
|
|||||||
resource: 'api.cluster.user@1.0'
|
resource: 'api.cluster.user@1.0'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'cluster/user/create',
|
||||||
|
component: CrudFormComponent,
|
||||||
|
data: {
|
||||||
|
breadcrumbs: 'Cluster/Users',
|
||||||
|
resource: 'api.cluster.user@1.0'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'monitor',
|
path: 'monitor',
|
||||||
component: MonitorComponent,
|
component: MonitorComponent,
|
||||||
|
@ -54,7 +54,7 @@ export class SubmitButtonComponent implements OnInit {
|
|||||||
constructor(private elRef: ElementRef) {}
|
constructor(private elRef: ElementRef) {}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.form.statusChanges.subscribe(() => {
|
this.form?.statusChanges.subscribe(() => {
|
||||||
if (_.has(this.form.errors, 'cdSubmitButton')) {
|
if (_.has(this.form.errors, 'cdSubmitButton')) {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
_.unset(this.form.errors, 'cdSubmitButton');
|
_.unset(this.form.errors, 'cdSubmitButton');
|
||||||
|
@ -0,0 +1,20 @@
|
|||||||
|
<div class="cd-col-form">
|
||||||
|
<div class="card pb-0">
|
||||||
|
<div i18n="form title"
|
||||||
|
class="card-header">{{ title }}</div>
|
||||||
|
|
||||||
|
<div class="card-body position-relative">
|
||||||
|
<json-schema-form
|
||||||
|
*ngIf="controlSchema && uiSchema"
|
||||||
|
[schema]="controlSchema"
|
||||||
|
[layout]="uiSchema"
|
||||||
|
[data]="data"
|
||||||
|
[widgets]="widgets"
|
||||||
|
(onSubmit)="submit($event)"
|
||||||
|
[options]="formOptions"
|
||||||
|
framework="bootstrap-4">
|
||||||
|
</json-schema-form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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<CrudFormComponent>;
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
@ -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();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -1,10 +1,19 @@
|
|||||||
<ng-container *ngIf="meta">
|
<ng-container *ngIf="meta">
|
||||||
<cd-table
|
<cd-table
|
||||||
[data]="data$ | async"
|
[data]="data$ | async"
|
||||||
[columns]="meta.table.columns"
|
[columns]="meta.table.columns"
|
||||||
[columnMode]="meta.table.columnMode"
|
[columnMode]="meta.table.columnMode"
|
||||||
[toolHeader]="meta.table.toolHeader"
|
[toolHeader]="meta.table.toolHeader">
|
||||||
></cd-table>
|
<div class="table-actions btn-toolbar">
|
||||||
|
<cd-table-actions [permission]="permission"
|
||||||
|
[selection]="selection"
|
||||||
|
class="btn-group"
|
||||||
|
id="crud-table-actions"
|
||||||
|
[tableActions]="meta.actions">
|
||||||
|
</cd-table-actions>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</cd-table>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|
||||||
<ng-template #badgeDictTpl
|
<ng-template #badgeDictTpl
|
||||||
|
@ -7,6 +7,9 @@ import { Observable } from 'rxjs';
|
|||||||
import { CrudMetadata } from '~/app/shared/models/crud-table-metadata';
|
import { CrudMetadata } from '~/app/shared/models/crud-table-metadata';
|
||||||
import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
|
import { DataGatewayService } from '~/app/shared/services/data-gateway.service';
|
||||||
import { TimerService } from '~/app/shared/services/timer.service';
|
import { TimerService } from '~/app/shared/services/timer.service';
|
||||||
|
import { CdTableSelection } from '../../models/cd-table-selection';
|
||||||
|
import { Permission, Permissions } from '../../models/permissions';
|
||||||
|
import { AuthStorageService } from '../../services/auth-storage.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'cd-crud-table',
|
selector: 'cd-crud-table',
|
||||||
@ -20,28 +23,45 @@ export class CRUDTableComponent implements OnInit {
|
|||||||
data$: Observable<any>;
|
data$: Observable<any>;
|
||||||
meta$: Observable<CrudMetadata>;
|
meta$: Observable<CrudMetadata>;
|
||||||
meta: CrudMetadata;
|
meta: CrudMetadata;
|
||||||
|
permissions: Permissions;
|
||||||
|
permission: Permission;
|
||||||
|
selection = new CdTableSelection();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
|
private authStorageService: AuthStorageService,
|
||||||
private timerService: TimerService,
|
private timerService: TimerService,
|
||||||
private dataGatewayService: DataGatewayService,
|
private dataGatewayService: DataGatewayService,
|
||||||
private activatedRoute: ActivatedRoute
|
private activatedRoute: ActivatedRoute
|
||||||
) {}
|
) {
|
||||||
|
this.permissions = this.authStorageService.getPermissions();
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
/* The following should be simplified with a wrapper that
|
/* The following should be simplified with a wrapper that
|
||||||
converts .data to @Input args. For example:
|
converts .data to @Input args. For example:
|
||||||
https://medium.com/@andrewcherepovskiy/passing-route-params-into-angular-components-input-properties-fc85c34c9aca
|
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;
|
const resource: string = data.resource;
|
||||||
this.dataGatewayService
|
this.dataGatewayService
|
||||||
.list(`ui-${resource}`)
|
.list(`ui-${resource}`)
|
||||||
.subscribe((response) => this.processMeta(response));
|
.subscribe((response: any) => this.processMeta(response));
|
||||||
this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource));
|
this.data$ = this.timerService.get(() => this.dataGatewayService.list(resource));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
processMeta(meta: CrudMetadata) {
|
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;
|
this.meta = meta;
|
||||||
const templates = {
|
const templates = {
|
||||||
badgeDict: this.badgeDictTpl
|
badgeDict: this.badgeDictTpl
|
||||||
|
@ -14,6 +14,8 @@ import { TableActionsComponent } from './table-actions/table-actions.component';
|
|||||||
import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
|
import { TableKeyValueComponent } from './table-key-value/table-key-value.component';
|
||||||
import { TablePaginationComponent } from './table-pagination/table-pagination.component';
|
import { TablePaginationComponent } from './table-pagination/table-pagination.component';
|
||||||
import { TableComponent } from './table/table.component';
|
import { TableComponent } from './table/table.component';
|
||||||
|
import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
|
||||||
|
import { CrudFormComponent } from './crud-table/crud-form/crud-form.component';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [
|
imports: [
|
||||||
@ -25,14 +27,16 @@ import { TableComponent } from './table/table.component';
|
|||||||
NgbTooltipModule,
|
NgbTooltipModule,
|
||||||
PipesModule,
|
PipesModule,
|
||||||
ComponentsModule,
|
ComponentsModule,
|
||||||
RouterModule
|
RouterModule,
|
||||||
|
Bootstrap4FrameworkModule
|
||||||
],
|
],
|
||||||
declarations: [
|
declarations: [
|
||||||
TableComponent,
|
TableComponent,
|
||||||
TableKeyValueComponent,
|
TableKeyValueComponent,
|
||||||
TableActionsComponent,
|
TableActionsComponent,
|
||||||
CRUDTableComponent,
|
CRUDTableComponent,
|
||||||
TablePaginationComponent
|
TablePaginationComponent,
|
||||||
|
CrudFormComponent
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
TableComponent,
|
TableComponent,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
|
import { CdTableColumn } from '~/app/shared/models/cd-table-column';
|
||||||
|
import { CdTableAction } from './cd-table-action';
|
||||||
|
|
||||||
class Table {
|
class Table {
|
||||||
columns: CdTableColumn[];
|
columns: CdTableColumn[];
|
||||||
@ -8,4 +9,7 @@ class Table {
|
|||||||
|
|
||||||
export class CrudMetadata {
|
export class CrudMetadata {
|
||||||
table: Table;
|
table: Table;
|
||||||
|
permissions: string[];
|
||||||
|
actions: CdTableAction[];
|
||||||
|
forms: any;
|
||||||
}
|
}
|
||||||
|
@ -12,16 +12,31 @@ export class DataGatewayService {
|
|||||||
constructor(private http: HttpClient) {}
|
constructor(private http: HttpClient) {}
|
||||||
|
|
||||||
list(dataPath: string): Observable<any> {
|
list(dataPath: string): Observable<any> {
|
||||||
if (this.cache[dataPath] === undefined) {
|
const cacheable = this.getCacheable(dataPath, 'get');
|
||||||
|
if (this.cache[cacheable] === undefined) {
|
||||||
const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
|
const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
|
||||||
const url = match.groups.url.split('.').join('/');
|
const url = match.groups.url.split('.').join('/');
|
||||||
const version = match.groups.version || '1.0';
|
const version = match.groups.version || '1.0';
|
||||||
|
|
||||||
this.cache[dataPath] = this.http.get<any>(url, {
|
this.cache[cacheable] = this.http.get<any>(url, {
|
||||||
headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
|
headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.cache[dataPath];
|
return this.cache[cacheable];
|
||||||
|
}
|
||||||
|
|
||||||
|
create(dataPath: string, data: any): Observable<any> {
|
||||||
|
const match = dataPath.match(/(?<url>[^@]+)(?:@(?<version>.+))?/);
|
||||||
|
const url = match.groups.url.split('.').join('/');
|
||||||
|
const version = match.groups.version || '1.0';
|
||||||
|
|
||||||
|
return this.http.post<any>(url, data, {
|
||||||
|
headers: { Accept: `application/vnd.ceph.api.v${version}+json` }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
getCacheable(dataPath: string, method: string) {
|
||||||
|
return dataPath + method;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -339,6 +339,9 @@ export class TaskMessageService {
|
|||||||
),
|
),
|
||||||
'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
|
'service/delete': this.newTaskMessage(this.commonOperations.delete, (metadata) =>
|
||||||
this.service(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}'`;
|
return $localize`Service '${metadata.service_name}'`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cephUser(metadata: any) {
|
||||||
|
return $localize`Ceph User '${metadata.user_entity}'`;
|
||||||
|
}
|
||||||
|
|
||||||
_getTaskTitle(task: Task) {
|
_getTaskTitle(task: Task) {
|
||||||
if (task.name && task.name.startsWith('progress/')) {
|
if (task.name && task.name.startsWith('progress/')) {
|
||||||
// we don't fill the failure string because, at least for now, all
|
// we don't fill the failure string because, at least for now, all
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { NgModule } from '@angular/core';
|
import { NgModule } from '@angular/core';
|
||||||
|
import { Bootstrap4FrameworkModule } from '@ajsf/bootstrap4';
|
||||||
|
|
||||||
import { CssHelper } from '~/app/shared/classes/css-helper';
|
import { CssHelper } from '~/app/shared/classes/css-helper';
|
||||||
import { ComponentsModule } from './components/components.module';
|
import { ComponentsModule } from './components/components.module';
|
||||||
@ -11,7 +12,14 @@ import { AuthStorageService } from './services/auth-storage.service';
|
|||||||
import { FormatterService } from './services/formatter.service';
|
import { FormatterService } from './services/formatter.service';
|
||||||
|
|
||||||
@NgModule({
|
@NgModule({
|
||||||
imports: [CommonModule, PipesModule, ComponentsModule, DataTableModule, DirectivesModule],
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
PipesModule,
|
||||||
|
ComponentsModule,
|
||||||
|
DataTableModule,
|
||||||
|
DirectivesModule,
|
||||||
|
Bootstrap4FrameworkModule
|
||||||
|
],
|
||||||
declarations: [],
|
declarations: [],
|
||||||
exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
|
exports: [ComponentsModule, PipesModule, DataTableModule, DirectivesModule],
|
||||||
providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper]
|
providers: [AuthStorageService, AuthGuardService, FormatterService, CssHelper]
|
||||||
|
@ -217,3 +217,17 @@ a.btn-light {
|
|||||||
.badge-dark {
|
.badge-dark {
|
||||||
@extend .badge, .bg-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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -80,6 +80,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-default {
|
||||||
|
@extend .btn-light;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary .badge {
|
.btn-primary .badge {
|
||||||
background-color: vv.$gray-200;
|
background-color: vv.$gray-200;
|
||||||
color: vv.$primary;
|
color: vv.$primary;
|
||||||
|
@ -2147,6 +2147,8 @@ paths:
|
|||||||
- Cluster
|
- Cluster
|
||||||
/api/cluster/user:
|
/api/cluster/user:
|
||||||
get:
|
get:
|
||||||
|
description: "\n Get list of ceph users and its respective data\n \
|
||||||
|
\ "
|
||||||
parameters: []
|
parameters: []
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
@ -2168,6 +2170,49 @@ paths:
|
|||||||
summary: Get Ceph Users
|
summary: Get Ceph Users
|
||||||
tags:
|
tags:
|
||||||
- Cluster
|
- 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:
|
/api/cluster_conf:
|
||||||
get:
|
get:
|
||||||
parameters: []
|
parameters: []
|
||||||
|
@ -8,3 +8,4 @@ rstcheck==3.3.1
|
|||||||
autopep8==1.5.7
|
autopep8==1.5.7
|
||||||
pyfakefs==4.5.0
|
pyfakefs==4.5.0
|
||||||
isort==5.5.3
|
isort==5.5.3
|
||||||
|
jsonschema==4.16.0
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
pytest-cov
|
pytest-cov
|
||||||
pytest-instafail
|
pytest-instafail
|
||||||
pyfakefs==4.5.0
|
pyfakefs==4.5.0
|
||||||
|
jsonschema==4.16.0
|
||||||
|
@ -11,3 +11,4 @@ pytest
|
|||||||
pyyaml
|
pyyaml
|
||||||
natsort
|
natsort
|
||||||
setuptools
|
setuptools
|
||||||
|
jsonpatch
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import unittest.mock as mock
|
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
|
from ..tests import ControllerTestCase
|
||||||
|
|
||||||
auth_dump_mock = {"auth_dump": [
|
auth_dump_mock = {"auth_dump": [
|
||||||
@ -41,3 +43,8 @@ class CephUsersControllerTestCase(ControllerTestCase):
|
|||||||
"key": "***********"
|
"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'])
|
||||||
|
@ -4,8 +4,11 @@ import json
|
|||||||
from typing import NamedTuple
|
from typing import NamedTuple
|
||||||
|
|
||||||
import pytest
|
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):
|
def assertObjectEquals(a, b):
|
||||||
@ -26,10 +29,41 @@ class NamedTupleSecretMock(NamedTuple):
|
|||||||
@pytest.mark.parametrize("inp,out", [
|
@pytest.mark.parametrize("inp,out", [
|
||||||
(["foo", "var"], ["foo", "var"]),
|
(["foo", "var"], ["foo", "var"]),
|
||||||
(NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}),
|
(NamedTupleMock(1, "test"), {"foo": 1, "var": "test"}),
|
||||||
(NamedTupleSecretMock(1, "test", "supposethisisakey"), {"foo": 1, "var": "test",
|
(NamedTupleSecretMock(1, "test", "imaginethisisakey"), {"foo": 1, "var": "test",
|
||||||
"key": "***********"}),
|
"key": "***********"}),
|
||||||
((1, 2, 3), [1, 2, 3]),
|
((1, 2, 3), [1, 2, 3]),
|
||||||
(set((1, 2, 3)), [1, 2, 3]),
|
(set((1, 2, 3)), [1, 2, 3]),
|
||||||
])
|
])
|
||||||
def test_serialize(inp, out):
|
def test_serialize(inp, out):
|
||||||
assertObjectEquals(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'])
|
||||||
|
Loading…
Reference in New Issue
Block a user