perf: change mcp integrate

This commit is contained in:
ibuler 2025-04-25 17:25:20 +08:00 committed by 老广
parent dc6308b030
commit 471053e62a
15 changed files with 601 additions and 74 deletions

View File

@ -34,10 +34,14 @@ class SimpleMetadataWithFilters(SimpleMetadata):
"""
actions = {}
view.raw_action = getattr(view, "action", None)
query_action = request.query_params.get("action", None)
for method in self.methods & set(view.allowed_methods):
if hasattr(view, "action_map"):
view.action = view.action_map.get(method.lower(), view.action)
if query_action and query_action.lower() != method.lower():
continue
view.request = clone_request(request, method)
try:
# Test global permissions

View File

@ -0,0 +1,3 @@
from .aggregate import *
from .dashboard import IndexApi
from .health import PrometheusMetricsApi, HealthCheckView

View File

@ -0,0 +1,9 @@
from .detail import ResourceDetailApi
from .list import ResourceListApi
from .supported import ResourceTypeListApi
__all__ = [
'ResourceListApi',
'ResourceDetailApi',
'ResourceTypeListApi',
]

View File

@ -0,0 +1,57 @@
list_params = [
{
"name": "search",
"in": "query",
"description": "A search term.",
"required": False,
"type": "string"
},
{
"name": "order",
"in": "query",
"description": "Which field to use when ordering the results.",
"required": False,
"type": "string"
},
{
"name": "limit",
"in": "query",
"description": "Number of results to return per page. Default is 10.",
"required": False,
"type": "integer"
},
{
"name": "offset",
"in": "query",
"description": "The initial index from which to return the results.",
"required": False,
"type": "integer"
},
]
common_params = [
{
"name": "resource",
"in": "path",
"description": """Resource to query, e.g. users, assets, permissions, acls, user-groups, policies, nodes, hosts,
devices, clouds, webs, databases,
gpts, ds, customs, platforms, zones, gateways, protocol-settings, labels, virtual-accounts,
gathered-accounts, account-templates, account-template-secrets, account-backups, account-backup-executions,
change-secret-automations, change-secret-executions, change-secret-records, gather-account-automations,
gather-account-executions, push-account-automations, push-account-executions, push-account-records,
check-account-automations, check-account-executions, account-risks, integration-apps, asset-permissions,
zones, gateways, virtual-accounts, gathered-accounts, account-templates, account-template-secrets,,
GET /api/v1/resources/ to get full supported resource.
""",
"required": True,
"type": "string"
},
{
"name": "X-JMS-ORG",
"in": "header",
"description": "The organization ID to use for the request. Organization is the namespace for resources, if not set, use default org",
"required": False,
"type": "string"
}
]

View File

@ -0,0 +1,73 @@
# views.py
from drf_yasg.utils import swagger_auto_schema
from rest_framework.permissions import IsAuthenticated
from rest_framework.views import APIView
from .const import common_params
from .proxy import ProxyMixin
from .utils import param_dic_to_param
one_param = [
{
'name': 'id',
'in': 'path',
'required': True,
'description': 'Resource ID',
'type': 'string',
}
]
object_params = [
param_dic_to_param(d)
for d in common_params + one_param
]
class ResourceDetailApi(ProxyMixin, APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_id="get_resource_detail",
operation_summary="Get resource detail",
manual_parameters=object_params,
operation_description="""
Get resource detail.
{resource} is the resource name, GET /api/v1/resources/ to get full supported resource.
""", )
def get(self, request, resource, pk=None):
return self._proxy(request, resource, pk=pk, action='retrieve')
@swagger_auto_schema(
operation_id="delete_resource",
operation_summary="Delete the resource ",
manual_parameters=object_params,
operation_description="Delete the resource, and can not be restored",
)
def delete(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='destroy')
@swagger_auto_schema(
operation_id="update_resource",
operation_summary="Update the resource property",
manual_parameters=object_params,
operation_description="""
Update the resource property, all property will be update,
{resource} is the resource name, GET /api/v1/resources/ to get full supported resource.
OPTION /api/v1/resources/{resource}/{id}/?action=put to get field type and helptext.
""")
def put(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='update')
@swagger_auto_schema(
operation_id="partial_update_resource",
operation_summary="Update the resource property",
manual_parameters=object_params,
operation_description="""
Partial update the resource property, only request property will be update,
OPTION /api/v1/resources/{resource}/{id}/?action=patch to get field type and helptext.
""")
def patch(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='partial_update')

View File

@ -0,0 +1,86 @@
# views.py
from drf_yasg import openapi
from drf_yasg.utils import swagger_auto_schema
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .const import list_params, common_params
from .proxy import ProxyMixin
from .utils import param_dic_to_param
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
list_params = [
param_dic_to_param(d)
for d in list_params + common_params
]
create_params = [
param_dic_to_param(d)
for d in common_params
]
list_schema = {
"required": [
"count",
"results"
],
"type": "object",
"properties": {
"count": {
"type": "integer"
},
"next": {
"type": "string",
"format": "uri",
"x-nullable": True
},
"previous": {
"type": "string",
"format": "uri",
"x-nullable": True
},
"results": {
"type": "array",
"items": {
}
}
}
}
list_response = openapi.Response("Resource list response", schema=openapi.Schema(**list_schema))
class ResourceListApi(ProxyMixin, APIView):
@swagger_auto_schema(
operation_id="get_resource_list",
operation_summary="Get resource list",
manual_parameters=list_params,
responses={200: list_response},
operation_description="""
Get resource list, you should set the resource name in the url.
OPTIONS /api/v1/resources/{resource}/?action=get to get every type resource's field type and help text.
""", )
# ↓↓↓ Swagger 自动文档 ↓↓↓
def get(self, request, resource):
return self._proxy(request, resource)
@swagger_auto_schema(
operation_id="create_resource_by_type",
operation_summary="Create resource",
manual_parameters=create_params,
operation_description="""
Create resource,
OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and helptext, and
you will know how to create it.
""")
def post(self, request, resource, pk=None):
if not resource:
resource = request.data.pop('resource', '')
return self._proxy(request, resource, pk, action='create')
def options(self, request, resource, pk=None):
return self._proxy(request, resource, pk, action='metadata')

View File

@ -0,0 +1,68 @@
# views.py
from urllib.parse import urlencode
import requests
from rest_framework.exceptions import NotFound, APIException
from rest_framework.permissions import AllowAny
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .utils import get_full_resource_map
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
class ProxyMixin(APIView):
"""
通用资源代理 API支持动态路径自动文档生成
"""
permission_classes = [AllowAny]
def _build_url(self, resource_name: str, pk: str = None, query_params=None):
resource_map = get_full_resource_map()
resource = resource_map.get(resource_name)
if not resource:
raise NotFound(f"Unknown resource: {resource_name}")
base_path = resource['path']
if pk:
base_path += f"{pk}/"
if query_params:
base_path += f"?{urlencode(query_params)}"
return f"{BASE_URL}{base_path}"
def _proxy(self, request, resource: str, pk: str = None, action='list'):
method = request.method.lower()
if method not in ['get', 'post', 'put', 'patch', 'delete', 'options']:
raise APIException("Unsupported method")
if not resource or resource == '{resource}':
if request.data:
resource = request.data.get('resource')
query_params = request.query_params.dict()
if action == 'list':
query_params['limit'] = 10
url = self._build_url(resource, pk, query_params)
headers = {k: v for k, v in request.headers.items() if k.lower() != 'host'}
cookies = request.COOKIES
body = request.body if method in ['post', 'put', 'patch'] else None
try:
resp = requests.request(
method=method,
url=url,
headers=headers,
cookies=cookies,
data=body,
timeout=10,
)
return resp
except requests.RequestException as e:
raise APIException(f"Proxy request failed: {str(e)}")

View File

@ -0,0 +1,43 @@
# views.py
from drf_yasg.utils import swagger_auto_schema
from rest_framework import serializers
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
class ResourceTypeResourceSerializer(serializers.Serializer):
name = serializers.CharField()
path = serializers.CharField()
app = serializers.CharField()
verbose_name = serializers.CharField()
description = serializers.CharField()
class ResourceTypeListApi(APIView):
permission_classes = [IsAuthenticated]
@swagger_auto_schema(
operation_id="get_supported_resources",
operation_summary="Get-all-support-resources",
operation_description="Get all support resources, name, path, verbose_name description",
responses={200: ResourceTypeResourceSerializer(many=True)}, # Specify the response serializer
)
def get(self, request):
result = []
resource_map = get_full_resource_map()
for name, desc in resource_map.items():
desc = resource_map.get(name, {})
resource = {
"name": name,
**desc,
"path": f'/api/v1/resources/{name}/',
}
result.append(resource)
return Response(result)

View File

@ -0,0 +1,128 @@
# views.py
import re
from functools import lru_cache
from typing import Dict
from django.urls import URLPattern
from django.urls import URLResolver
from drf_yasg import openapi
from rest_framework.routers import DefaultRouter
router = DefaultRouter()
BASE_URL = "http://localhost:8080"
def clean_path(path: str) -> str:
"""
清理掉 DRF 自动生成的正则格式内容让其变成普通 RESTful URL path
"""
# 去掉格式后缀匹配: \.(?P<format>xxx)
path = re.sub(r'\\\.\(\?P<format>[^)]+\)', '', path)
# 去掉括号式格式匹配
path = re.sub(r'\(\?P<format>[^)]+\)', '', path)
# 移除 DRF 中正则参数的部分 (?P<param>pattern)
path = re.sub(r'\(\?P<\w+>[^)]+\)', '{param}', path)
# 如果有多个括号包裹的正则(比如前缀路径),去掉可选部分包装
path = re.sub(r'\(\(([^)]+)\)\?\)', r'\1', path) # ((...))? => ...
# 去掉中间和两边的 ^ 和 $
path = path.replace('^', '').replace('$', '')
# 去掉尾部 ?/
path = re.sub(r'\?/?$', '', path)
# 去掉反斜杠
path = path.replace('\\', '')
# 替换多重斜杠
path = re.sub(r'/+', '/', path)
# 添加开头斜杠,移除多余空格
path = path.strip()
if not path.startswith('/'):
path = '/' + path
if not path.endswith('/'):
path += '/'
return path
def extract_resource_paths(urlpatterns, prefix='/api/v1/') -> Dict[str, Dict[str, str]]:
resource_map = {}
for pattern in urlpatterns:
if isinstance(pattern, URLResolver):
nested_prefix = prefix + str(pattern.pattern)
resource_map.update(extract_resource_paths(pattern.url_patterns, nested_prefix))
elif isinstance(pattern, URLPattern):
callback = pattern.callback
actions = getattr(callback, 'actions', {})
if not actions:
continue
if 'get' in actions and actions['get'] == 'list':
path = clean_path(prefix + str(pattern.pattern))
# 尝试获取资源名称
name = pattern.name
if name and name.endswith('-list'):
resource = name[:-5]
else:
resource = path.strip('/').split('/')[-1]
# 不强行加 s资源名保持原状即可
resource = resource if resource.endswith('s') else resource + 's'
# 获取 View 类和 model 的 verbose_name
view_cls = getattr(callback, 'cls', None)
model = None
if view_cls:
queryset = getattr(view_cls, 'queryset', None)
if queryset is not None:
model = getattr(queryset, 'model', None)
else:
# 有些 View 用 get_queryset()
try:
instance = view_cls()
qs = instance.get_queryset()
model = getattr(qs, 'model', None)
except Exception:
pass
if not model:
continue
app = str(getattr(model._meta, 'app_label', ''))
verbose_name = str(getattr(model._meta, 'verbose_name', ''))
resource_map[resource] = {
'path': path,
'app': app,
'verbose_name': verbose_name,
'description': model.__doc__.__str__()
}
print("Extracted resource paths:", list(resource_map.keys()))
return resource_map
def param_dic_to_param(d):
return openapi.Parameter(
d['name'], d['in'],
description=d['description'], type=d['type'], required=d.get('required', False)
)
@lru_cache()
def get_full_resource_map():
from apps.jumpserver.urls import resource_api
resource_map = extract_resource_paths(resource_api)
print("Building URL for resource:", resource_map)
return resource_map

View File

@ -1,15 +1,11 @@
import time
from collections import defaultdict
from django.core.cache import cache
from django.db.models import Count, Max, F, CharField
from django.db.models.functions import Cast
from django.http.response import JsonResponse, HttpResponse
from django.http.response import JsonResponse
from django.utils import timezone
from django.utils.timesince import timesince
from rest_framework.permissions import AllowAny
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
from assets.const import AllTypes
@ -25,8 +21,6 @@ from orgs.caches import OrgResourceStatisticsCache
from orgs.utils import current_org
from terminal.const import RiskLevelChoices
from terminal.models import Session, Command
from terminal.utils import ComponentsPrometheusMetricsUtil
from users.models import User
__all__ = ['IndexApi']
@ -466,61 +460,3 @@ class IndexApi(DateTimeMixin, DatesLoginMetricMixin, APIView):
})
return JsonResponse(data, status=200)
class HealthApiMixin(APIView):
pass
class HealthCheckView(HealthApiMixin):
permission_classes = (AllowAny,)
@staticmethod
def get_db_status():
t1 = time.time()
try:
ok = User.objects.first() is not None
t2 = time.time()
return ok, t2 - t1
except Exception as e:
return False, str(e)
@staticmethod
def get_redis_status():
key = 'HEALTH_CHECK'
t1 = time.time()
try:
value = '1'
cache.set(key, '1', 10)
got = cache.get(key)
t2 = time.time()
if value == got:
return True, t2 - t1
return False, 'Value not match'
except Exception as e:
return False, str(e)
def get(self, request):
redis_status, redis_time = self.get_redis_status()
db_status, db_time = self.get_db_status()
status = all([redis_status, db_status])
data = {
'status': status,
'db_status': db_status,
'db_time': db_time,
'redis_status': redis_status,
'redis_time': redis_time,
'time': int(time.time()),
}
return Response(data)
class PrometheusMetricsApi(HealthApiMixin):
permission_classes = (AllowAny,)
def get(self, request, *args, **kwargs):
util = ComponentsPrometheusMetricsUtil()
metrics_text = util.get_prometheus_metrics_text()
return HttpResponse(metrics_text, content_type='text/plain; version=0.0.4; charset=utf-8')

View File

@ -0,0 +1,68 @@
import time
from django.core.cache import cache
from django.http.response import HttpResponse
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.views import APIView
from terminal.utils import ComponentsPrometheusMetricsUtil
from users.models import User
class HealthApiMixin(APIView):
pass
class HealthCheckView(HealthApiMixin):
permission_classes = (AllowAny,)
@staticmethod
def get_db_status():
t1 = time.time()
try:
ok = User.objects.first() is not None
t2 = time.time()
return ok, t2 - t1
except Exception as e:
return False, str(e)
@staticmethod
def get_redis_status():
key = 'HEALTH_CHECK'
t1 = time.time()
try:
value = '1'
cache.set(key, '1', 10)
got = cache.get(key)
t2 = time.time()
if value == got:
return True, t2 - t1
return False, 'Value not match'
except Exception as e:
return False, str(e)
def get(self, request):
redis_status, redis_time = self.get_redis_status()
db_status, db_time = self.get_db_status()
status = all([redis_status, db_status])
data = {
'status': status,
'db_status': db_status,
'db_time': db_time,
'redis_status': redis_status,
'redis_time': redis_time,
'time': int(time.time()),
}
return Response(data)
class PrometheusMetricsApi(HealthApiMixin):
permission_classes = (AllowAny,)
def get(self, request, *args, **kwargs):
util = ComponentsPrometheusMetricsUtil()
metrics_text = util.get_prometheus_metrics_text()
return HttpResponse(metrics_text, content_type='text/plain; version=0.0.4; charset=utf-8')

View File

@ -12,7 +12,7 @@ from django.views.i18n import JavaScriptCatalog
from . import views, api
api_v1 = [
resource_api = [
path('index/', api.IndexApi.as_view()),
path('users/', include('users.urls.api_urls', namespace='api-users')),
path('assets/', include('assets.urls.api_urls', namespace='api-assets')),
@ -30,7 +30,13 @@ api_v1 = [
path('notifications/', include('notifications.urls.api_urls', namespace='api-notifications')),
path('rbac/', include('rbac.urls.api_urls', namespace='api-rbac')),
path('labels/', include('labels.urls', namespace='api-label')),
]
api_v1 = resource_api + [
path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()),
path('resources/', api.ResourceTypeListApi.as_view(), name='resource-list'),
path('resources/<str:resource>/', api.ResourceListApi.as_view()),
path('resources/<str:resource>/<str:pk>/', api.ResourceDetailApi.as_view()),
]
app_view_patterns = [

View File

@ -19,41 +19,78 @@ class CustomSchemaGenerator(OpenAPISchemaGenerator):
'/report/', '/render-to-json/', '/suggestions/',
'executions', 'automations', 'change-secret-records',
'change-secret-dashboard', '/copy-to-assets/',
'/move-to-assets/', 'dashboard',
'/move-to-assets/', 'dashboard', 'index', 'countries',
'/resources/cache/', 'profile/mfa', 'profile/password',
'profile/permissions', 'prometheus', 'constraints'
]
for p in excludes:
if path.find(p) >= 0:
return True
return False
def exclude_some_app(self, path):
def exclude_some_app_model(self, path):
parts = path.split('/')
if len(parts) < 4:
if len(parts) < 5:
return False
apps = []
if self.from_mcp:
apps = [
'ops', 'tickets', 'common', 'authentication',
'settings', 'xpack', 'terminal', 'rbac'
'ops', 'tickets', 'authentication',
'settings', 'xpack', 'terminal', 'rbac',
'notifications', 'promethues', 'acls'
]
app_name = parts[3]
if app_name in apps:
return True
models = []
model = parts[4]
if self.from_mcp:
models = [
'users', 'user-groups', 'users-groups-relations', 'assets', 'hosts', 'devices', 'databases',
'webs', 'clouds', 'gpts', 'ds', 'customs', 'platforms', 'nodes', 'zones', 'gateways',
'protocol-settings', 'labels', 'virtual-accounts', 'gathered-accounts', 'account-templates',
'account-template-secrets', 'account-backups', 'account-backup-executions',
'change-secret-automations', 'change-secret-executions', 'change-secret-records',
'gather-account-automations', 'gather-account-executions', 'push-account-automations',
'push-account-executions', 'push-account-records', 'check-account-automations',
'check-account-executions', 'account-risks', 'integration-apps', 'asset-permissions',
'asset-permissions-users-relations', 'asset-permissions-user-groups-relations',
'asset-permissions-assets-relations', 'asset-permissions-nodes-relations', 'terminal-status',
'terminals', 'tasks', 'status', 'replay-storages', 'command-storages', 'session-sharing-records',
'endpoints', 'endpoint-rules', 'applets', 'applet-hosts', 'applet-publications',
'applet-host-deployments', 'virtual-apps', 'app-providers', 'virtual-app-publications',
'celery-period-tasks', 'task-executions', 'adhocs', 'playbooks', 'variables', 'ftp-logs',
'login-logs', 'operate-logs', 'password-change-logs', 'job-logs', 'jobs', 'user-sessions',
'service-access-logs', 'chatai-prompts', 'super-connection-tokens', 'flows',
'apply-assets', 'apply-nodes', 'login-acls', 'login-asset-acls', 'command-filter-acls',
'command-groups', 'connect-method-acls', 'system-msg-subscriptions', 'roles', 'role-bindings',
'system-roles', 'system-role-bindings', 'org-roles', 'org-role-bindings', 'content-types',
'labeled-resources', 'account-backup-plans', 'account-check-engines', 'account-secrets',
'change-secret', 'integration-applications', 'push-account', 'directories', 'connection-token',
'groups', 'accounts', 'resource-types', 'favorite-assets', 'activities', 'platform-automation-methods',
]
if model in models:
return True
return False
def get_operation(self, view, path, prefix, method, components, request):
# 这里可以对 path 进行处理
if self.exclude_some_paths(path):
return None
if self.exclude_some_app(path):
if self.exclude_some_app_model(path):
return None
operation = super().get_operation(view, path, prefix, method, components, request)
operation_id = operation.get('operationId')
if 'bulk' in operation_id:
return None
exclude_operations = [
'orgs_orgs_read', 'orgs_orgs_update', 'orgs_orgs_delete', 'orgs_orgs_create',
'orgs_orgs_partial_update',
]
if operation_id in exclude_operations:
return None
return operation
@ -82,7 +119,8 @@ class CustomSwaggerAutoSchema(SwaggerAutoSchema):
def get_operation(self, operation_keys):
operation = super().get_operation(operation_keys)
operation.summary = operation.operation_id
if not getattr(operation, 'summary', ''):
operation.summary = operation.operation_id
return operation
def get_filter_parameters(self):

View File

@ -10,6 +10,9 @@ __all__ = ['UserGroup']
class UserGroup(LabeledMixin, JMSOrgBaseModel):
"""
User group, When a user is added to a group, they inherit its asset permissions for access control consistency.
"""
name = models.CharField(max_length=128, verbose_name=_('Name'))
def __str__(self):

View File

@ -55,6 +55,11 @@ class User(
JSONFilterMixin,
AbstractUser,
):
"""
User model, used for authentication and authorization. User can join multiple groups.
User can have multiple roles, and each role can have multiple permissions.
User can connect to multiple assets, If he has the permission. Permission was defined in Asset Permission.
"""
id = models.UUIDField(default=uuid.uuid4, primary_key=True)
username = models.CharField(max_length=128, unique=True, verbose_name=_("Username"))
name = models.CharField(max_length=128, verbose_name=_("Name"))