Merge remote-tracking branch 'origin/feature-855' into feature-855
# Conflicts: # admin/dashboard/blueprints/system.py
This commit is contained in:
@@ -7,42 +7,11 @@ from flask import Blueprint, request, jsonify, render_template, current_app
|
||||
|
||||
from admin.dashboard.blueprints.auth import login_required
|
||||
from utils.robot_cmd.robot_command import GroupBotManager, PermissionStatus
|
||||
from plugins.robot_menu.menu_render_tool import RobotMenuRenderTool
|
||||
|
||||
# 创建蓝图
|
||||
plugin_routes = Blueprint('plugin_routes', __name__)
|
||||
LOG = logger
|
||||
|
||||
# 后台命令索引页只复用“命令目录生成”能力,不需要图片渲染,
|
||||
# 因此这里固定使用轻量 text 配置创建一个工具实例即可。
|
||||
_command_catalog_tool = RobotMenuRenderTool(
|
||||
output_mode="text",
|
||||
image_fallback_to_text=True,
|
||||
image_render_timeout_seconds=30,
|
||||
image_render_retries=1,
|
||||
image_template_path="plugins/robot_menu/templates/menu_cards.html",
|
||||
log=LOG,
|
||||
)
|
||||
|
||||
|
||||
def _build_group_options(server) -> list:
|
||||
"""构建后台命令索引页的群组选项列表。"""
|
||||
group_ids = sorted(set(GroupBotManager.get_group_list() or []))
|
||||
options = []
|
||||
for group_id in group_ids:
|
||||
group_name = ""
|
||||
try:
|
||||
group_name = server.contact_manager.get_nickname(group_id) or ""
|
||||
except Exception:
|
||||
group_name = ""
|
||||
options.append(
|
||||
{
|
||||
"group_id": group_id,
|
||||
"group_name": str(group_name or group_id),
|
||||
}
|
||||
)
|
||||
return options
|
||||
|
||||
|
||||
# 机器人管理页面
|
||||
@plugin_routes.route('/plugins_manage')
|
||||
@@ -51,61 +20,40 @@ def robot_management():
|
||||
return render_template('plugins_manage.html')
|
||||
|
||||
|
||||
@plugin_routes.route('/command_catalog')
|
||||
@login_required
|
||||
def command_catalog_page():
|
||||
"""后台命令索引页面。"""
|
||||
return render_template('command_catalog.html')
|
||||
|
||||
|
||||
@plugin_routes.route('/api/plugins', methods=['GET'])
|
||||
@login_required
|
||||
def get_plugins():
|
||||
"""获取所有插件列表"""
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
# 统一改为消费 PluginManager 的标准治理快照:
|
||||
# 1. 这样既能覆盖“已加载插件”,也能覆盖“发现但加载失败/配置禁用”的模块;
|
||||
# 2. 后台不必重复拼装版本、命令、依赖、配置健康等字段;
|
||||
# 3. 后续继续补错误统计、性能排名时,也只需要在快照层扩展。
|
||||
plugin_list = server.plugin_manager.get_plugin_snapshots()
|
||||
# 获取插件注册表
|
||||
plugins = server.plugin_registry.get_all_plugins()
|
||||
|
||||
# 转换为前端需要的格式
|
||||
plugin_list = []
|
||||
for name, plugin in plugins.items():
|
||||
# 获取插件模块名
|
||||
try:
|
||||
module_name = plugin.__class__.__module__.split('.')[-2]
|
||||
except (IndexError, AttributeError):
|
||||
module_name = "unknown"
|
||||
|
||||
plugin_info = {
|
||||
"name": plugin.name,
|
||||
"module_name": module_name,
|
||||
"version": getattr(plugin, 'version', 'N/A'),
|
||||
"author": getattr(plugin, 'author', 'N/A'),
|
||||
"description": getattr(plugin, 'description', 'N/A'),
|
||||
"status": plugin.status.name if hasattr(plugin, 'status') else 'UNKNOWN'
|
||||
}
|
||||
plugin_list.append(plugin_info)
|
||||
|
||||
return jsonify({"success": True, "data": plugin_list})
|
||||
except Exception as e:
|
||||
LOG.error(f"获取插件列表失败: {str(e)}", exc_info=True)
|
||||
return jsonify({"success": False, "message": f"获取插件列表失败: {str(e)}"})
|
||||
|
||||
|
||||
@plugin_routes.route('/api/plugins/command_catalog', methods=['GET'])
|
||||
@login_required
|
||||
def get_command_catalog():
|
||||
"""获取后台命令索引数据。"""
|
||||
try:
|
||||
server = current_app.dashboard_server
|
||||
group_id = str(request.args.get('group_id') or '').strip()
|
||||
|
||||
# 后台命令索引默认站在“管理员”视角,
|
||||
# 这样既能看到当前可用命令,也能看到未启用能力和管理指令。
|
||||
catalog = _command_catalog_tool.build_command_catalog_data(
|
||||
group_id=group_id,
|
||||
requester_id="dashboard_admin",
|
||||
force_admin=True,
|
||||
)
|
||||
data = {
|
||||
**catalog,
|
||||
"group_options": _build_group_options(server),
|
||||
"summary": {
|
||||
"available_manual_count": len(catalog.get("available_manual", []) or []),
|
||||
"available_auto_count": len(catalog.get("available_auto", []) or []),
|
||||
"unavailable_manual_count": len(catalog.get("unavailable_manual", []) or []),
|
||||
"admin_command_count": len(catalog.get("admin_commands", []) or []),
|
||||
},
|
||||
}
|
||||
return jsonify({"success": True, "data": data})
|
||||
except Exception as e:
|
||||
LOG.error(f"获取命令索引失败: {str(e)}", exc_info=True)
|
||||
return jsonify({"success": False, "message": f"获取命令索引失败: {str(e)}"})
|
||||
|
||||
|
||||
@plugin_routes.route('/api/plugins/group_status', methods=['GET'])
|
||||
@login_required
|
||||
def get_plugin_group_status():
|
||||
@@ -245,10 +193,31 @@ def get_plugin_info():
|
||||
if not plugin_name:
|
||||
return jsonify({"success": False, "message": "缺少插件名称参数"})
|
||||
|
||||
plugin_info = server.plugin_manager.get_plugin_snapshot(plugin_name)
|
||||
if not plugin_info:
|
||||
# 获取插件管理器
|
||||
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
|
||||
|
||||
if not plugin:
|
||||
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
|
||||
|
||||
# 获取插件模块名
|
||||
try:
|
||||
module_name = plugin.__class__.__module__.split('.')[-2]
|
||||
except (IndexError, AttributeError):
|
||||
module_name = "unknown"
|
||||
|
||||
# 构建详细信息
|
||||
plugin_info = {
|
||||
"name": plugin.name,
|
||||
"module_name": module_name,
|
||||
"version": getattr(plugin, 'version', 'N/A'),
|
||||
"author": getattr(plugin, 'author', 'N/A'),
|
||||
"description": getattr(plugin, 'description', 'N/A'),
|
||||
"status": plugin.status.name if hasattr(plugin, 'status') else 'UNKNOWN',
|
||||
"command_prefix": getattr(plugin, 'command_prefix', ''),
|
||||
"commands": getattr(plugin, 'commands', []),
|
||||
"config": getattr(plugin, '_config', {})
|
||||
}
|
||||
|
||||
return jsonify({"success": True, "data": plugin_info})
|
||||
except Exception as e:
|
||||
LOG.error(f"获取插件详情失败: {str(e)}", exc_info=True)
|
||||
@@ -266,14 +235,9 @@ def enable_plugin():
|
||||
if not plugin_name:
|
||||
return jsonify({"success": False, "message": "缺少插件名称参数"})
|
||||
|
||||
# 已加载插件直接启动;尚未加载的插件则先尝试加载,再进入启动流程。
|
||||
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
|
||||
if not plugin:
|
||||
plugin = server.plugin_manager.load_plugin(plugin_name)
|
||||
if plugin:
|
||||
display_name = plugin.name
|
||||
|
||||
if plugin and server.plugin_manager.start_plugin(display_name or plugin_name):
|
||||
# 获取插件管理器
|
||||
# 启用插件
|
||||
if server.plugin_manager.start_plugin(plugin_name):
|
||||
return jsonify({"success": True, "message": f"插件 {plugin_name} 启用成功"})
|
||||
else:
|
||||
return jsonify({"success": False, "message": f"插件 {plugin_name} 启用失败"})
|
||||
@@ -314,14 +278,8 @@ def reload_plugin():
|
||||
if not plugin_name:
|
||||
return jsonify({"success": False, "message": "缺少插件名称参数"})
|
||||
|
||||
# 已加载插件优先走重载;若当前未加载,则退化为“重新尝试加载并启动”。
|
||||
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
|
||||
if plugin:
|
||||
reloaded_plugin = server.plugin_manager.reload_plugin(plugin_name)
|
||||
else:
|
||||
reloaded_plugin = server.plugin_manager.load_plugin(plugin_name)
|
||||
if reloaded_plugin:
|
||||
server.plugin_manager.start_plugin(reloaded_plugin.name)
|
||||
# 重载插件
|
||||
reloaded_plugin = server.plugin_manager.reload_plugin(plugin_name)
|
||||
|
||||
if reloaded_plugin:
|
||||
return jsonify({"success": True, "message": f"插件 {plugin_name} 重载成功"})
|
||||
@@ -342,11 +300,16 @@ def get_raw_plugin_config():
|
||||
if not plugin_name:
|
||||
return jsonify({"success": False, "message": "缺少插件名称参数"})
|
||||
|
||||
plugin_snapshot = server.plugin_manager.get_plugin_snapshot(plugin_name)
|
||||
if not plugin_snapshot:
|
||||
# 获取插件管理器
|
||||
|
||||
# 查找插件
|
||||
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
|
||||
|
||||
if not plugin:
|
||||
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
|
||||
|
||||
config_path = str(plugin_snapshot.get("config_path", "") or "").strip()
|
||||
# 获取配置文件路径
|
||||
config_path = plugin.get_config_path()
|
||||
|
||||
if not os.path.exists(config_path):
|
||||
return jsonify({"success": False, "message": f"配置文件不存在: {config_path}"})
|
||||
@@ -386,29 +349,15 @@ def update_plugin_config():
|
||||
if not plugin_name or config_text is None:
|
||||
return jsonify({"success": False, "message": "缺少必要参数"})
|
||||
|
||||
# 查找插件
|
||||
# 获取插件管理器
|
||||
display_name, plugin = server.plugin_manager.find_plugin_by_name(plugin_name)
|
||||
plugin_snapshot = server.plugin_manager.get_plugin_snapshot(plugin_name)
|
||||
if not plugin_snapshot:
|
||||
|
||||
if not plugin:
|
||||
return jsonify({"success": False, "message": f"未找到插件: {plugin_name}"})
|
||||
|
||||
config_path = str(plugin_snapshot.get("config_path", "") or "").strip()
|
||||
if not config_path:
|
||||
return jsonify({"success": False, "message": "插件未声明配置路径,暂不支持在线编辑"})
|
||||
|
||||
# 保存前先做格式校验:
|
||||
# 1. 避免把坏 TOML 先写回磁盘,再让插件进入“文件已坏但提示成功”的状态;
|
||||
# 2. 校验通过后再真正落盘,失败则保留线上旧配置;
|
||||
# 3. 这也是插件治理中心第一阶段的“配置校验底座”。
|
||||
try:
|
||||
if format_type == 'toml':
|
||||
config_obj = toml.loads(config_text)
|
||||
elif format_type == 'json':
|
||||
config_obj = json.loads(config_text)
|
||||
else:
|
||||
return jsonify({"success": False, "message": f"不支持的配置格式: {format_type}"})
|
||||
except Exception as parse_error:
|
||||
LOG.error(f"解析配置失败: {str(parse_error)}", exc_info=True)
|
||||
return jsonify({"success": False, "message": f"配置格式校验失败: {str(parse_error)}"})
|
||||
# 获取配置文件路径
|
||||
config_path = plugin.get_config_path()
|
||||
|
||||
# 确保配置目录存在
|
||||
os.makedirs(os.path.dirname(config_path), exist_ok=True)
|
||||
@@ -417,11 +366,22 @@ def update_plugin_config():
|
||||
with open(config_path, 'w', encoding='utf-8') as f:
|
||||
f.write(config_text)
|
||||
|
||||
# 若插件当前已加载,则同步刷新内存中的配置镜像,减少“保存后详情弹窗仍是旧配置”的困惑。
|
||||
if plugin:
|
||||
# 解析配置并更新插件内部配置
|
||||
try:
|
||||
if format_type == 'toml':
|
||||
config_obj = toml.loads(config_text)
|
||||
elif format_type == 'json':
|
||||
config_obj = json.loads(config_text)
|
||||
else:
|
||||
return jsonify({"success": False, "message": f"不支持的配置格式: {format_type}"})
|
||||
|
||||
# 更新插件内部配置
|
||||
plugin._config = config_obj
|
||||
|
||||
return jsonify({"success": True, "message": "配置已保存并通过格式校验"})
|
||||
return jsonify({"success": True, "message": "配置已保存"})
|
||||
except Exception as e:
|
||||
LOG.error(f"解析配置失败: {str(e)}", exc_info=True)
|
||||
return jsonify({"success": False, "message": f"配置已保存,但解析失败: {str(e)}"})
|
||||
|
||||
except Exception as e:
|
||||
LOG.error(f"更新插件配置失败: {str(e)}", exc_info=True)
|
||||
|
||||
@@ -40,7 +40,7 @@ def api_list_schedules():
|
||||
data = server.plugin_schedule_manager.list_schedules_with_runtime()
|
||||
# 后端统一格式化时间字段,避免前端出现 Fri, 17 Apr 2026 ... 这类 RFC 时间串。
|
||||
for row in data:
|
||||
for key in ("next_run_at", "last_run_at", "latest_success_at", "latest_failed_at", "created_at", "updated_at"):
|
||||
for key in ("next_run_at", "last_run_at", "created_at", "updated_at"):
|
||||
if key in row:
|
||||
row[key] = _normalize_datetime_text(row.get(key))
|
||||
return jsonify({"success": True, "data": data})
|
||||
|
||||
@@ -21,40 +21,6 @@ def _normalize_datetime_text(value):
|
||||
return text
|
||||
|
||||
|
||||
def _build_job_health_status(*, enabled: bool, running: bool, last_status: str, latest_success_at, latest_failure_summary: str) -> str:
|
||||
"""根据任务启停、运行态和历史结果输出后台可读的健康状态。"""
|
||||
# 状态设计尽量贴近运维判断顺序:
|
||||
# 1. 停用态单独标记,避免和“从未执行”混淆;
|
||||
# 2. 执行中的任务优先展示 running,方便后台快速识别实时动作;
|
||||
# 3. 最近一次执行失败时直接标记 failed,让异常任务在列表里一眼可见;
|
||||
# 4. 有成功历史且最近不是失败时视为 healthy,否则落到 idle。
|
||||
if not enabled:
|
||||
return "disabled"
|
||||
if running:
|
||||
return "running"
|
||||
if str(last_status or "").strip().lower() == "failed":
|
||||
return "failed"
|
||||
if latest_success_at or str(last_status or "").strip().lower() == "success":
|
||||
return "healthy"
|
||||
if str(latest_failure_summary or "").strip():
|
||||
return "failed"
|
||||
return "idle"
|
||||
|
||||
|
||||
def _build_job_health_message(*, health_status: str, latest_success_at, latest_failure_summary: str) -> str:
|
||||
"""为后台列表生成一句简短的任务健康提示。"""
|
||||
if health_status == "disabled":
|
||||
return "任务已停用"
|
||||
if health_status == "running":
|
||||
return "任务正在执行中"
|
||||
if health_status == "failed":
|
||||
return str(latest_failure_summary or "最近一次执行失败").strip()
|
||||
if health_status == "healthy":
|
||||
success_text = _normalize_datetime_text(latest_success_at)
|
||||
return f"最近成功于 {success_text}" if success_text else "任务近期执行正常"
|
||||
return "暂无执行记录"
|
||||
|
||||
|
||||
@system_jobs_bp.route("/")
|
||||
@login_required
|
||||
def page_system_jobs():
|
||||
@@ -68,31 +34,11 @@ def api_list_jobs():
|
||||
db_rows = server.system_job_db.list_jobs()
|
||||
runtime_rows = async_job.get_jobs_snapshot()
|
||||
runtime_by_key = {row.get("job_key", ""): row for row in runtime_rows if row.get("job_key")}
|
||||
job_keys = [str(row.get("job_key") or "").strip() for row in db_rows if str(row.get("job_key") or "").strip()]
|
||||
latest_log_by_key = server.system_job_db.get_latest_logs_map(job_keys)
|
||||
history_summary_by_key = server.system_job_db.get_job_history_summary_map(job_keys)
|
||||
|
||||
result = []
|
||||
for row in db_rows:
|
||||
job_key = row.get("job_key")
|
||||
runtime = runtime_by_key.get(job_key, {})
|
||||
latest_log = latest_log_by_key.get(job_key, {})
|
||||
history_summary = history_summary_by_key.get(job_key, {})
|
||||
last_status = runtime.get("last_status") or latest_log.get("status") or "never"
|
||||
last_run_at = runtime.get("last_run_at") or latest_log.get("triggered_at")
|
||||
last_error = runtime.get("last_error") or ""
|
||||
if not last_error and str(last_status or "").strip().lower() == "failed":
|
||||
last_error = (
|
||||
str(latest_log.get("summary") or "").strip()
|
||||
or str(history_summary.get("latest_failure_summary") or "").strip()
|
||||
)
|
||||
health_status = _build_job_health_status(
|
||||
enabled=bool(row.get("enabled", 0)),
|
||||
running=bool(runtime.get("running", False)),
|
||||
last_status=str(last_status or ""),
|
||||
latest_success_at=history_summary.get("latest_success_at"),
|
||||
latest_failure_summary=str(history_summary.get("latest_failure_summary") or ""),
|
||||
)
|
||||
result.append(
|
||||
{
|
||||
"job_key": job_key,
|
||||
@@ -105,26 +51,14 @@ def api_list_jobs():
|
||||
"runtime_enabled": runtime.get("enabled"),
|
||||
"running": runtime.get("running", False),
|
||||
"trigger_text": runtime.get("trigger_text", ""),
|
||||
"last_run_at": _normalize_datetime_text(last_run_at),
|
||||
"last_status": last_status,
|
||||
"last_error": last_error,
|
||||
"last_duration_ms": runtime.get("last_duration_ms") or latest_log.get("duration_ms"),
|
||||
"last_run_at": _normalize_datetime_text(runtime.get("last_run_at")),
|
||||
"last_status": runtime.get("last_status"),
|
||||
"last_error": runtime.get("last_error"),
|
||||
"last_duration_ms": runtime.get("last_duration_ms"),
|
||||
"next_run_at": _normalize_datetime_text(runtime.get("next_run_at")),
|
||||
"run_count": runtime.get("run_count", 0),
|
||||
"success_count": runtime.get("success_count", 0),
|
||||
"fail_count": runtime.get("fail_count", 0),
|
||||
"latest_success_at": _normalize_datetime_text(history_summary.get("latest_success_at")),
|
||||
"latest_failed_at": _normalize_datetime_text(history_summary.get("latest_failed_at")),
|
||||
"latest_failure_summary": str(history_summary.get("latest_failure_summary") or "").strip(),
|
||||
"history_success_count": int(history_summary.get("history_success_count", 0) or 0),
|
||||
"history_fail_count": int(history_summary.get("history_fail_count", 0) or 0),
|
||||
"history_total_count": int(history_summary.get("history_total_count", 0) or 0),
|
||||
"health_status": health_status,
|
||||
"health_message": _build_job_health_message(
|
||||
health_status=health_status,
|
||||
latest_success_at=history_summary.get("latest_success_at"),
|
||||
latest_failure_summary=str(history_summary.get("latest_failure_summary") or "").strip(),
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@@ -1012,7 +1012,6 @@
|
||||
items: [
|
||||
{ label: '插件统计', path: '/plugins' },
|
||||
{ label: '插件管理', path: '/plugins_manage' },
|
||||
{ label: '命令索引', path: '/command_catalog' },
|
||||
{ label: '插件定时任务', path: '/plugin_schedules' },
|
||||
{ label: '群级插件配置', path: '/group_plugin_config' },
|
||||
{ label: '响应指令管理', path: '/fun_command_rules' },
|
||||
@@ -1153,7 +1152,6 @@
|
||||
'12': '/virtual_group',
|
||||
'13': '/api_docs',
|
||||
'14': '/system_status',
|
||||
'18': '/command_catalog',
|
||||
'17': '/system_llm',
|
||||
'15': '/file_browser',
|
||||
'16': '/message_push'
|
||||
|
||||
@@ -1,478 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}命令索引 - 机器人管理后台{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-shell command-catalog-page">
|
||||
<div class="page-hero">
|
||||
<div class="page-hero-copy">
|
||||
<div class="page-eyebrow">Command Catalog</div>
|
||||
<h1>命令索引</h1>
|
||||
<p>集中查看当前插件命令、群可用状态、自动能力与管理员触发示例,减少靠记忆找功能的成本。</p>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-select v-model="selectedGroupId" clearable filterable placeholder="选择群查看实际可用状态" @change="loadCatalog" class="hero-select">
|
||||
<el-option
|
||||
v-for="group in groupOptions"
|
||||
:key="group.group_id"
|
||||
:label="group.group_name"
|
||||
:value="group.group_id">
|
||||
</el-option>
|
||||
</el-select>
|
||||
<el-button type="primary" @click="loadCatalog">
|
||||
<i class="el-icon-refresh"></i> 刷新索引
|
||||
</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="overview-grid">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card overview-card--primary" shadow="hover">
|
||||
<div class="overview-label">可用手动命令</div>
|
||||
<div class="overview-value">{% raw %}{{ summary.available_manual_count || 0 }}{% endraw %}</div>
|
||||
<div class="overview-note">当前群可以直接触发的消息命令</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card" shadow="hover">
|
||||
<div class="overview-label">自动能力</div>
|
||||
<div class="overview-value">{% raw %}{{ summary.available_auto_count || 0 }}{% endraw %}</div>
|
||||
<div class="overview-note">无需手动发送指令的自动/定时能力</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card overview-card--soft" shadow="hover">
|
||||
<div class="overview-label">未启用命令</div>
|
||||
<div class="overview-value">{% raw %}{{ summary.unavailable_manual_count || 0 }}{% endraw %}</div>
|
||||
<div class="overview-note">管理员视角下可看到但当前群不可用的命令</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card" shadow="hover">
|
||||
<div class="overview-label">管理命令</div>
|
||||
<div class="overview-value">{% raw %}{{ summary.admin_command_count || 0 }}{% endraw %}</div>
|
||||
<div class="overview-note">群开关、管理员维护等后台辅助命令</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="workspace-card workspace-card--filters" shadow="hover">
|
||||
<div class="workspace-header workspace-header--compact">
|
||||
<div>
|
||||
<h3>筛选条件</h3>
|
||||
<p>支持按命令、插件名、描述关键词快速定位。</p>
|
||||
</div>
|
||||
<el-input
|
||||
v-model.trim="searchKeyword"
|
||||
clearable
|
||||
placeholder="搜索命令 / 插件 / 描述"
|
||||
class="search-input">
|
||||
<i slot="prefix" class="el-input__icon el-icon-search"></i>
|
||||
</el-input>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>当前可用命令</h3>
|
||||
<p>这里展示的是当前群在管理员视角下“真实可触发”的命令入口。</p>
|
||||
</div>
|
||||
<div class="workspace-meta">
|
||||
{% raw %}{{ activeGroupLabel }}{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-table :data="filteredAvailableManual" style="width: 100%" v-loading="loading" empty-text="当前没有可直接使用的命令">
|
||||
<el-table-column label="插件" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
|
||||
<div class="entity-subtitle">{% raw %}{{ scope.row.description }}{% endraw %}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="主指令" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
<span class="mono-text">{% raw %}{{ scope.row.primary_command || '-' }}{% endraw %}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="别名" min-width="220">
|
||||
<template slot-scope="scope">
|
||||
<div class="tag-row" v-if="scope.row.alias_commands && scope.row.alias_commands.length">
|
||||
<el-tag v-for="alias in scope.row.alias_commands" :key="alias" size="mini" effect="plain">
|
||||
{% raw %}{{ alias }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="类别" width="110" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="small" type="success">{% raw %}{{ scope.row.category_label }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="可用原因" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
{% raw %}{{ scope.row.availability_reason || '-' }}{% endraw %}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="Feature Key" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
<span class="mono-text">{% raw %}{{ scope.row.feature_key || '-' }}{% endraw %}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="16" class="insight-grid">
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>未启用命令</h3>
|
||||
<p>这部分只在后台管理员视角展示,便于你知道还有哪些能力没在当前群打开。</p>
|
||||
</div>
|
||||
</div>
|
||||
<el-table :data="filteredUnavailableManual" style="width: 100%" v-loading="loading" empty-text="当前没有未启用命令">
|
||||
<el-table-column label="插件" min-width="160">
|
||||
<template slot-scope="scope">
|
||||
<div class="entity-title">{% raw %}{{ scope.row.name }}{% endraw %}</div>
|
||||
<div class="entity-subtitle">{% raw %}{{ scope.row.description }}{% endraw %}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="命令" min-width="150">
|
||||
<template slot-scope="scope">
|
||||
<span class="mono-text">{% raw %}{{ scope.row.primary_command || '-' }}{% endraw %}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="原因" min-width="140">
|
||||
<template slot-scope="scope">
|
||||
<el-tag size="mini" type="warning">{% raw %}{{ scope.row.availability_reason || '-' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :lg="12">
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>自动/定时能力</h3>
|
||||
<p>用于提醒你哪些功能不是靠用户发命令触发,而是自动执行。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list">
|
||||
<div v-if="filteredAutoCommands.length === 0" class="empty-state">当前没有已启用的自动能力</div>
|
||||
<div v-for="item in filteredAutoCommands" :key="item.module_name" class="info-item">
|
||||
<div class="info-item__head">
|
||||
<div class="info-item__title">{% raw %}{{ item.name }}{% endraw %}</div>
|
||||
<el-tag size="mini" type="info">{% raw %}{{ item.category_label }}{% endraw %}</el-tag>
|
||||
</div>
|
||||
<div class="info-item__desc">{% raw %}{{ item.description }}{% endraw %}</div>
|
||||
<div class="info-item__meta">{% raw %}{{ item.availability_reason || '自动执行' }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>管理命令示例</h3>
|
||||
<p>给管理员的常用操作命令,适合快速开关功能和维护群管理员。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-list info-list--grid">
|
||||
<div v-if="!adminCommands.length" class="empty-state">当前没有管理命令示例</div>
|
||||
<div v-for="item in adminCommands" :key="item.example" class="info-item">
|
||||
<div class="info-item__head">
|
||||
<div class="info-item__title">{% raw %}{{ item.title }}{% endraw %}</div>
|
||||
</div>
|
||||
<div class="info-item__command mono-text">{% raw %}{{ item.example }}{% endraw %}</div>
|
||||
<div class="info-item__desc">{% raw %}{{ item.description }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
new Vue({
|
||||
el: '#app',
|
||||
mixins: [baseApp],
|
||||
data() {
|
||||
return {
|
||||
currentView: '18',
|
||||
loading: false,
|
||||
searchKeyword: '',
|
||||
selectedGroupId: '',
|
||||
groupOptions: [],
|
||||
summary: {
|
||||
available_manual_count: 0,
|
||||
available_auto_count: 0,
|
||||
unavailable_manual_count: 0,
|
||||
admin_command_count: 0
|
||||
},
|
||||
availableManual: [],
|
||||
unavailableManual: [],
|
||||
autoCommands: [],
|
||||
adminCommands: [],
|
||||
generatedAt: ''
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
activeGroupLabel() {
|
||||
if (!this.selectedGroupId) {
|
||||
return '当前视角:全部运行中插件(未指定群)';
|
||||
}
|
||||
const matched = (this.groupOptions || []).find(item => item.group_id === this.selectedGroupId);
|
||||
return `当前视角:${matched ? matched.group_name : this.selectedGroupId}`;
|
||||
},
|
||||
filteredAvailableManual() {
|
||||
return this.filterCommandItems(this.availableManual);
|
||||
},
|
||||
filteredUnavailableManual() {
|
||||
return this.filterCommandItems(this.unavailableManual);
|
||||
},
|
||||
filteredAutoCommands() {
|
||||
return this.filterCommandItems(this.autoCommands);
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.currentView = '18';
|
||||
this.loadCatalog();
|
||||
},
|
||||
methods: {
|
||||
filterCommandItems(items) {
|
||||
const keyword = String(this.searchKeyword || '').trim().toLowerCase();
|
||||
if (!keyword) return items || [];
|
||||
return (items || []).filter(item => {
|
||||
const aliasText = ((item.alias_commands || []).join(' ') || '').toLowerCase();
|
||||
const fullText = [
|
||||
item.name,
|
||||
item.description,
|
||||
item.primary_command,
|
||||
aliasText,
|
||||
item.feature_key,
|
||||
item.availability_reason
|
||||
].join(' ').toLowerCase();
|
||||
return fullText.includes(keyword);
|
||||
});
|
||||
},
|
||||
loadCatalog() {
|
||||
this.loading = true;
|
||||
axios.get('/api/plugins/command_catalog', {
|
||||
params: {
|
||||
group_id: this.selectedGroupId
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (response.data.success) {
|
||||
const payload = response.data.data || {};
|
||||
this.groupOptions = payload.group_options || [];
|
||||
this.summary = payload.summary || this.summary;
|
||||
this.availableManual = payload.available_manual || [];
|
||||
this.unavailableManual = payload.unavailable_manual || [];
|
||||
this.autoCommands = payload.available_auto || [];
|
||||
this.adminCommands = payload.admin_commands || [];
|
||||
this.generatedAt = payload.generated_at || '';
|
||||
} else {
|
||||
this.$message.error(response.data.message || '加载命令索引失败');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('加载命令索引出错:', error);
|
||||
this.$message.error('加载命令索引出错');
|
||||
})
|
||||
.finally(() => {
|
||||
this.loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
{% block styles %}
|
||||
<style>
|
||||
.page-shell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.page-hero {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
padding: 24px 26px;
|
||||
border-radius: 24px;
|
||||
background: linear-gradient(135deg, rgba(79,70,229,0.10), rgba(59,130,246,0.08), rgba(255,255,255,0.92));
|
||||
border: 1px solid rgba(148, 163, 184, 0.16);
|
||||
box-shadow: 0 18px 40px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
.page-eyebrow {
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: .08em;
|
||||
color: #6366f1;
|
||||
font-weight: 700;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.page-hero-copy h1 {
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 10px;
|
||||
color: #0f172a;
|
||||
}
|
||||
.page-hero-copy p {
|
||||
color: #64748b;
|
||||
font-size: 14px;
|
||||
}
|
||||
.page-hero-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.hero-select {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.overview-grid .el-col,
|
||||
.insight-grid .el-col {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.overview-card { min-height: 112px; }
|
||||
.overview-card--primary {
|
||||
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important;
|
||||
}
|
||||
.overview-card--soft {
|
||||
background: linear-gradient(180deg, rgba(59,130,246,0.08), rgba(255,255,255,0.94)) !important;
|
||||
}
|
||||
.overview-label { font-size: 13px; color: #64748b; margin-bottom: 14px; }
|
||||
.overview-value { font-size: 30px; font-weight: 700; color: #0f172a; margin-bottom: 10px; }
|
||||
.overview-note { font-size: 12px; color: #94a3b8; }
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.workspace-header--compact {
|
||||
align-items: flex-end;
|
||||
}
|
||||
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
|
||||
.workspace-header p { font-size: 13px; color: #64748b; }
|
||||
.workspace-meta {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.search-input {
|
||||
width: 280px;
|
||||
max-width: 100%;
|
||||
}
|
||||
.entity-title {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
.entity-subtitle {
|
||||
margin-top: 4px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.mono-text {
|
||||
font-family: Consolas, "SFMono-Regular", Menlo, monospace;
|
||||
color: #334155;
|
||||
}
|
||||
.tag-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.info-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.info-list--grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.info-item {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(248,250,252,0.82);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.info-item__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.info-item__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
.info-item__command {
|
||||
margin-bottom: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.info-item__desc {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
word-break: break-word;
|
||||
}
|
||||
.info-item__meta {
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.empty-state {
|
||||
padding: 16px 12px;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 13px;
|
||||
background: rgba(248, 250, 252, 0.9);
|
||||
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||
border-radius: 14px;
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.page-hero,
|
||||
.workspace-header,
|
||||
.workspace-header--compact {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.page-hero-actions,
|
||||
.hero-select,
|
||||
.search-input {
|
||||
width: 100%;
|
||||
}
|
||||
.info-list--grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.page-hero {
|
||||
padding: 18px 16px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
.page-hero-copy h1 {
|
||||
font-size: 24px;
|
||||
}
|
||||
.workspace-card .el-card__body {
|
||||
padding: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -131,7 +131,7 @@
|
||||
<div class="section-heading section-heading--stack">
|
||||
<div>
|
||||
<h3>系统健康快照</h3>
|
||||
<p>把连接状态、插件运行、异常数量、LLM 运行态与任务调度集中到一个面板里。</p>
|
||||
<p>把连接状态、插件运行、异常数量与转图运行时集中到一个面板里。</p>
|
||||
</div>
|
||||
<div class="health-overview-meta">
|
||||
<span class="health-overview-meta__label">最近刷新</span>
|
||||
@@ -148,29 +148,6 @@
|
||||
</div>
|
||||
<div class="health-item__value">{% raw %}{{ card.value }}{% endraw %}</div>
|
||||
<div class="health-item__summary">{% raw %}{{ card.summary }}{% endraw %}</div>
|
||||
<div v-if="card.serviceBlocks && card.serviceBlocks.length" class="health-service-grid">
|
||||
<div
|
||||
v-for="service in card.serviceBlocks"
|
||||
:key="service.key"
|
||||
class="health-service-panel"
|
||||
:class="`health-service-panel--${service.status}`">
|
||||
<div class="health-service-panel__head">
|
||||
<div>
|
||||
<div class="health-service-panel__title">{% raw %}{{ service.title }}{% endraw %}</div>
|
||||
<div class="health-service-panel__summary">{% raw %}{{ service.summary }}{% endraw %}</div>
|
||||
</div>
|
||||
<span class="health-service-panel__badge" :class="`health-service-panel__badge--${service.status}`">
|
||||
{% raw %}{{ getHealthStatusText(service.status) }}{% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="health-service-metrics">
|
||||
<div v-for="metric in service.metrics" :key="metric.label" class="health-service-metric">
|
||||
<span class="health-service-metric__label">{% raw %}{{ metric.label }}{% endraw %}</span>
|
||||
<span class="health-service-metric__value">{% raw %}{{ metric.value }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="card.extra" class="health-item__extra">{% raw %}{{ card.extra }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -394,38 +371,15 @@
|
||||
status: 'warning',
|
||||
total_calls: 0,
|
||||
failed_calls: 0,
|
||||
success_rate: 0,
|
||||
avg_latency_ms: 0,
|
||||
summary: '加载中...',
|
||||
last_call: {},
|
||||
scene_count: 0,
|
||||
target_count: 0,
|
||||
provider_count: 0,
|
||||
has_routing: false,
|
||||
default_scene: '',
|
||||
default_backend: '',
|
||||
last_provider: '',
|
||||
last_backend: '',
|
||||
last_scene: '',
|
||||
last_model: '',
|
||||
last_timestamp: '',
|
||||
last_latency_ms: 0,
|
||||
last_error: ''
|
||||
last_call: {}
|
||||
},
|
||||
scheduler: {
|
||||
md2img: {
|
||||
status: 'warning',
|
||||
total_jobs: 0,
|
||||
enabled_jobs: 0,
|
||||
running_jobs: 0,
|
||||
failed_jobs: 0,
|
||||
invalid_jobs: 0,
|
||||
paused_jobs: 0,
|
||||
never_run_jobs: 0,
|
||||
system_job_count: 0,
|
||||
plugin_job_count: 0,
|
||||
next_run_at: '',
|
||||
latest_failed_job_name: '',
|
||||
latest_failed_error: '',
|
||||
healthy: false,
|
||||
runtime_ready: false,
|
||||
browser_ready: false,
|
||||
summary: '加载中...'
|
||||
}
|
||||
},
|
||||
@@ -469,7 +423,7 @@
|
||||
const errors = this.healthSummary.errors || {};
|
||||
const infrastructure = this.healthSummary.infrastructure || {};
|
||||
const aiRuntime = this.healthSummary.ai_runtime || {};
|
||||
const scheduler = this.healthSummary.scheduler || {};
|
||||
const md2img = this.healthSummary.md2img || {};
|
||||
return [
|
||||
{
|
||||
key: 'robot',
|
||||
@@ -499,30 +453,25 @@
|
||||
key: 'infrastructure',
|
||||
title: '基础设施',
|
||||
status: infrastructure.status || 'warning',
|
||||
value: `${this.countHealthyInfrastructureServices(infrastructure)} / 2`,
|
||||
value: infrastructure.status === 'healthy' ? '正常' : '异常',
|
||||
summary: infrastructure.summary || '暂无状态',
|
||||
serviceBlocks: this.buildInfrastructureServiceBlocks(infrastructure),
|
||||
extra: '首页展示的是服务摘要;如果后续要做更深入的运维排查,再单独拆详细页会更合适。'
|
||||
extra: `MySQL:${((infrastructure.mysql || {}).status === 'healthy') ? '正常' : '异常'} / Redis:${((infrastructure.redis || {}).status === 'healthy') ? '正常' : '异常'}`
|
||||
},
|
||||
{
|
||||
key: 'ai_runtime',
|
||||
title: 'LLM 运行态',
|
||||
title: 'AI 运行态',
|
||||
status: aiRuntime.status || 'warning',
|
||||
value: (aiRuntime.total_calls || 0) > 0
|
||||
? `${this.formatMetricNumber(aiRuntime.success_rate, 2)}%`
|
||||
: `${aiRuntime.scene_count || 0} 个场景`,
|
||||
value: `${aiRuntime.avg_latency_ms || 0} ms`,
|
||||
summary: aiRuntime.summary || '暂无状态',
|
||||
serviceBlocks: this.buildAiRuntimeServiceBlocks(aiRuntime),
|
||||
extra: this.buildAiRuntimeExtra(aiRuntime)
|
||||
extra: `最近调用 ${aiRuntime.total_calls || 0} 次,失败 ${aiRuntime.failed_calls || 0} 次`
|
||||
},
|
||||
{
|
||||
key: 'scheduler',
|
||||
title: '任务调度',
|
||||
status: scheduler.status || 'warning',
|
||||
value: `${scheduler.enabled_jobs || 0} / ${scheduler.total_jobs || 0}`,
|
||||
summary: scheduler.summary || '暂无状态',
|
||||
serviceBlocks: this.buildSchedulerServiceBlocks(scheduler),
|
||||
extra: this.buildSchedulerExtra(scheduler)
|
||||
key: 'md2img',
|
||||
title: 'Markdown 转图',
|
||||
status: md2img.status || 'warning',
|
||||
value: md2img.healthy ? '就绪' : '待检查',
|
||||
summary: md2img.summary || '暂无状态',
|
||||
extra: `Runtime ${md2img.runtime_ready ? '已就绪' : '未就绪'} / Browser ${md2img.browser_ready ? '已就绪' : '未就绪'}`
|
||||
}
|
||||
];
|
||||
}
|
||||
@@ -590,235 +539,6 @@
|
||||
};
|
||||
return statusMap[status] || '未知';
|
||||
},
|
||||
formatCompactDuration(seconds) {
|
||||
const totalSeconds = parseInt(seconds) || 0;
|
||||
if (totalSeconds <= 0) return '-';
|
||||
const days = Math.floor(totalSeconds / 86400);
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
if (days > 0) return `${days}D ${hours}H`;
|
||||
if (hours > 0) return `${hours}H ${minutes}M`;
|
||||
return `${minutes}M`;
|
||||
},
|
||||
formatMetricNumber(value, fractionDigits = 0) {
|
||||
if (value === null || value === undefined || value === '') return '-';
|
||||
const numeric = Number(value);
|
||||
if (Number.isNaN(numeric)) return String(value);
|
||||
return numeric.toFixed(fractionDigits);
|
||||
},
|
||||
countHealthyInfrastructureServices(infrastructure) {
|
||||
const mysql = infrastructure.mysql || {};
|
||||
const redis = infrastructure.redis || {};
|
||||
let count = 0;
|
||||
if (mysql.status === 'healthy') count += 1;
|
||||
if (redis.status === 'healthy') count += 1;
|
||||
return count;
|
||||
},
|
||||
buildInfrastructureServiceBlocks(infrastructure) {
|
||||
const mysql = infrastructure.mysql || {};
|
||||
const redis = infrastructure.redis || {};
|
||||
return [
|
||||
{
|
||||
key: 'mysql',
|
||||
title: 'MySQL',
|
||||
status: mysql.status || 'warning',
|
||||
summary: mysql.summary || '暂无状态',
|
||||
metrics: [
|
||||
{
|
||||
label: '连接负载',
|
||||
value: `${this.formatMetricNumber(mysql.connection_usage_percent, 1)}%`
|
||||
},
|
||||
{
|
||||
label: '连接数',
|
||||
value: `${this.formatMetricNumber(mysql.threads_connected)} / ${mysql.max_connections || '-'}`
|
||||
},
|
||||
{
|
||||
label: '运行线程',
|
||||
value: this.formatMetricNumber(mysql.threads_running)
|
||||
},
|
||||
{
|
||||
label: 'QPS',
|
||||
value: this.formatMetricNumber(mysql.questions_per_second, 2)
|
||||
},
|
||||
{
|
||||
label: '库体积',
|
||||
value: `${this.formatMetricNumber(mysql.schema_size_mb, 2)} MB`
|
||||
},
|
||||
{
|
||||
label: '表数量',
|
||||
value: this.formatMetricNumber(mysql.table_count)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'redis',
|
||||
title: 'Redis',
|
||||
status: redis.status || 'warning',
|
||||
summary: redis.summary || '暂无状态',
|
||||
metrics: [
|
||||
{
|
||||
label: 'Key 数量',
|
||||
value: this.formatMetricNumber(redis.key_count)
|
||||
},
|
||||
{
|
||||
label: '客户端',
|
||||
value: this.formatMetricNumber(redis.connected_clients)
|
||||
},
|
||||
{
|
||||
label: 'OPS/s',
|
||||
value: this.formatMetricNumber(redis.ops_per_sec)
|
||||
},
|
||||
{
|
||||
label: '内存占用',
|
||||
value: redis.used_memory_human || '-'
|
||||
},
|
||||
{
|
||||
label: '命中率',
|
||||
value: `${this.formatMetricNumber(redis.hit_rate_percent, 1)}%`
|
||||
},
|
||||
{
|
||||
label: '运行时间',
|
||||
value: this.formatCompactDuration(redis.uptime_seconds)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
},
|
||||
buildAiRuntimeServiceBlocks(aiRuntime) {
|
||||
// AI 卡片拆成“路由配置”和“最近调用”两个子面板,
|
||||
// 让首页既能判断配置是否完整,也能快速定位最近请求到底走了哪条链路。
|
||||
return [
|
||||
{
|
||||
key: 'ai-routing',
|
||||
title: '路由配置',
|
||||
status: aiRuntime.has_routing ? 'healthy' : 'warning',
|
||||
summary: aiRuntime.default_scene
|
||||
? `默认场景:${aiRuntime.default_scene}`
|
||||
: '当前未设置默认场景',
|
||||
metrics: [
|
||||
{
|
||||
label: '场景数量',
|
||||
value: this.formatMetricNumber(aiRuntime.scene_count)
|
||||
},
|
||||
{
|
||||
label: '目标数量',
|
||||
value: this.formatMetricNumber(aiRuntime.target_count)
|
||||
},
|
||||
{
|
||||
label: 'Provider 模板',
|
||||
value: this.formatMetricNumber(aiRuntime.provider_count)
|
||||
},
|
||||
{
|
||||
label: '默认后端',
|
||||
value: aiRuntime.default_backend || '-'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'ai-last-call',
|
||||
title: '最近调用',
|
||||
status: (aiRuntime.failed_calls || 0) > 0 ? 'warning' : ((aiRuntime.total_calls || 0) > 0 ? 'healthy' : 'warning'),
|
||||
summary: aiRuntime.last_timestamp
|
||||
? `最近一次记录时间:${aiRuntime.last_timestamp}`
|
||||
: '当前窗口内暂无调用记录',
|
||||
metrics: [
|
||||
{
|
||||
label: 'Provider',
|
||||
value: aiRuntime.last_provider || '-'
|
||||
},
|
||||
{
|
||||
label: 'Backend',
|
||||
value: aiRuntime.last_backend || '-'
|
||||
},
|
||||
{
|
||||
label: 'Scene',
|
||||
value: aiRuntime.last_scene || '-'
|
||||
},
|
||||
{
|
||||
label: '模型',
|
||||
value: aiRuntime.last_model || '-'
|
||||
},
|
||||
{
|
||||
label: '最近耗时',
|
||||
value: `${this.formatMetricNumber(aiRuntime.last_latency_ms, 2)} ms`
|
||||
},
|
||||
{
|
||||
label: '最近错误',
|
||||
value: aiRuntime.last_error || '无'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
},
|
||||
buildAiRuntimeExtra(aiRuntime) {
|
||||
return `最近调用 ${aiRuntime.total_calls || 0} 次,失败 ${aiRuntime.failed_calls || 0} 次,平均耗时 ${this.formatMetricNumber(aiRuntime.avg_latency_ms, 2)} ms`;
|
||||
},
|
||||
buildSchedulerServiceBlocks(scheduler) {
|
||||
// 任务调度卡片只保留首页最需要的摘要:
|
||||
// 任务装载量、执行态、失败数,以及系统任务/插件任务的大致构成。
|
||||
return [
|
||||
{
|
||||
key: 'scheduler-overview',
|
||||
title: '任务装载',
|
||||
status: scheduler.enabled_jobs > 0 ? 'healthy' : 'warning',
|
||||
summary: scheduler.next_run_at
|
||||
? `下一次执行:${scheduler.next_run_at}`
|
||||
: '当前没有可计算的下一次执行时间',
|
||||
metrics: [
|
||||
{
|
||||
label: '启用任务',
|
||||
value: this.formatMetricNumber(scheduler.enabled_jobs)
|
||||
},
|
||||
{
|
||||
label: '暂停任务',
|
||||
value: this.formatMetricNumber(scheduler.paused_jobs)
|
||||
},
|
||||
{
|
||||
label: '系统任务',
|
||||
value: this.formatMetricNumber(scheduler.system_job_count)
|
||||
},
|
||||
{
|
||||
label: '插件任务',
|
||||
value: this.formatMetricNumber(scheduler.plugin_job_count)
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduler-runtime',
|
||||
title: '执行状态',
|
||||
status: scheduler.status || 'warning',
|
||||
summary: scheduler.latest_failed_job_name
|
||||
? `最近失败任务:${scheduler.latest_failed_job_name}`
|
||||
: '当前未发现最近失败任务',
|
||||
metrics: [
|
||||
{
|
||||
label: '执行中',
|
||||
value: this.formatMetricNumber(scheduler.running_jobs)
|
||||
},
|
||||
{
|
||||
label: '失败任务',
|
||||
value: this.formatMetricNumber(scheduler.failed_jobs)
|
||||
},
|
||||
{
|
||||
label: '非法调度',
|
||||
value: this.formatMetricNumber(scheduler.invalid_jobs)
|
||||
},
|
||||
{
|
||||
label: '未执行过',
|
||||
value: this.formatMetricNumber(scheduler.never_run_jobs)
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
},
|
||||
buildSchedulerExtra(scheduler) {
|
||||
if (scheduler.latest_failed_error) {
|
||||
return `最近失败原因:${scheduler.latest_failed_error}`;
|
||||
}
|
||||
return scheduler.next_run_at
|
||||
? `下次执行时间:${scheduler.next_run_at}`
|
||||
: '当前暂无可用的下一次执行时间';
|
||||
},
|
||||
renderPieChart(chartId, usageValue, label) {
|
||||
const ctx = document.getElementById(chartId);
|
||||
if (!ctx) return;
|
||||
@@ -1375,104 +1095,6 @@
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.health-service-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.health-service-panel {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.14);
|
||||
background: rgba(248, 250, 252, 0.72);
|
||||
}
|
||||
|
||||
.health-service-panel--healthy {
|
||||
box-shadow: inset 0 0 0 1px rgba(16, 185, 129, 0.08);
|
||||
}
|
||||
|
||||
.health-service-panel--warning {
|
||||
box-shadow: inset 0 0 0 1px rgba(245, 158, 11, 0.10);
|
||||
}
|
||||
|
||||
.health-service-panel--danger {
|
||||
box-shadow: inset 0 0 0 1px rgba(239, 68, 68, 0.10);
|
||||
}
|
||||
|
||||
.health-service-panel__head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.health-service-panel__title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.health-service-panel__summary {
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.health-service-panel__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
padding: 4px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-service-panel__badge--healthy {
|
||||
color: #047857;
|
||||
background: rgba(16, 185, 129, 0.12);
|
||||
}
|
||||
|
||||
.health-service-panel__badge--warning {
|
||||
color: #b45309;
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
}
|
||||
|
||||
.health-service-panel__badge--danger {
|
||||
color: #b91c1c;
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
}
|
||||
|
||||
.health-service-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 12px;
|
||||
}
|
||||
|
||||
.health-service-metric {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.health-service-metric__label {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.health-service-metric__value {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #1e293b;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.health-item__extra {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
@@ -1828,10 +1450,6 @@
|
||||
.health-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.health-service-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
@@ -1941,10 +1559,6 @@
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.health-service-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.chart-container--large,
|
||||
.chart-container--panel {
|
||||
height: 220px;
|
||||
|
||||
@@ -42,44 +42,11 @@
|
||||
<el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="健康状态" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="healthTag(scope.row.health_status)">{% raw %}{{ healthLabel(scope.row.health_status) }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近成功" min-width="165">
|
||||
<template slot-scope="scope">
|
||||
{% raw %}{{ formatDateTime(scope.row.latest_success_at) }}{% endraw %}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="最近失败原因" min-width="240">
|
||||
<template slot-scope="scope">
|
||||
<div class="cell-ellipsis" :title="scope.row.latest_failure_summary || scope.row.last_error || '-'">
|
||||
{% raw %}{{ scope.row.latest_failure_summary || scope.row.last_error || '-' }}{% endraw %}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="历史执行" width="150" align="center">
|
||||
<template slot-scope="scope">
|
||||
<div class="history-metrics">
|
||||
<span class="metric-success">{% raw %}{{ `成 ${scope.row.history_success_count || 0}` }}{% endraw %}</span>
|
||||
<span class="metric-fail">{% raw %}{{ `失 ${scope.row.history_fail_count || 0}` }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="history-total">{% raw %}{{ `共 ${scope.row.history_total_count || 0}` }}{% endraw %}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="360">
|
||||
<el-table-column label="操作" min-width="280">
|
||||
<template slot-scope="scope">
|
||||
<div class="action-row">
|
||||
<el-button size="mini" type="primary" plain @click="openEdit(scope.row)">编辑</el-button>
|
||||
<el-button size="mini" type="success" plain @click="triggerNow(scope.row)">立即触发</el-button>
|
||||
<el-button
|
||||
size="mini"
|
||||
:type="scope.row.enabled ? 'warning' : 'success'"
|
||||
plain
|
||||
@click="toggleEnabled(scope.row)">
|
||||
{% raw %}{{ scope.row.enabled ? '停用' : '启用' }}{% endraw %}
|
||||
</el-button>
|
||||
<el-button size="mini" type="text" @click="viewLogs(scope.row)">日志</el-button>
|
||||
</div>
|
||||
</template>
|
||||
@@ -230,25 +197,6 @@ new Vue({
|
||||
if (status === 'running') return 'warning'
|
||||
return 'info'
|
||||
},
|
||||
healthTag(status) {
|
||||
if (status === 'healthy') return 'success'
|
||||
if (status === 'running') return 'warning'
|
||||
if (status === 'failed') return 'danger'
|
||||
if (status === 'degraded') return 'warning'
|
||||
if (status === 'disabled') return 'info'
|
||||
return ''
|
||||
},
|
||||
healthLabel(status) {
|
||||
const mapping = {
|
||||
healthy: '健康',
|
||||
running: '执行中',
|
||||
failed: '异常',
|
||||
degraded: '有告警',
|
||||
disabled: '停用',
|
||||
idle: '待运行'
|
||||
}
|
||||
return mapping[status] || '待运行'
|
||||
},
|
||||
formatDateTime(value) {
|
||||
// 统一清洗时间展示:去掉 ISO 'T',并兼容字符串与日期对象。
|
||||
if (!value) return ''
|
||||
@@ -382,25 +330,6 @@ new Vue({
|
||||
}
|
||||
await this.loadSchedules()
|
||||
},
|
||||
async toggleEnabled(row) {
|
||||
const payload = {
|
||||
action_name: row.action_name,
|
||||
description: row.description,
|
||||
enabled: !row.enabled,
|
||||
trigger_type: row.trigger_type,
|
||||
trigger_config: row.trigger_config,
|
||||
target_scope: row.target_scope,
|
||||
target_config: row.target_config,
|
||||
payload: row.payload || {}
|
||||
}
|
||||
const resp = await axios.put(`/plugin_schedules/api/schedules/${row.id}`, payload)
|
||||
if (resp.data.success) {
|
||||
this.$message.success(row.enabled ? '已停用' : '已启用')
|
||||
await this.loadSchedules()
|
||||
} else {
|
||||
this.$message.error(resp.data.message || '更新失败')
|
||||
}
|
||||
},
|
||||
async viewLogs(row) {
|
||||
const resp = await axios.get(`/plugin_schedules/api/schedules/${row.id}/logs`)
|
||||
if (resp.data.success) {
|
||||
@@ -423,10 +352,5 @@ new Vue({
|
||||
.page-hero-copy p{color:#64748b;font-size:14px}
|
||||
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.detail-pre{white-space:pre-wrap;word-break:break-word;background:rgba(248,250,252,.85);border:1px solid rgba(148,163,184,.12);border-radius:14px;padding:10px;color:#334155}
|
||||
.cell-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569}
|
||||
.history-metrics{display:flex;align-items:center;justify-content:center;gap:8px}
|
||||
.metric-success{color:#16a34a;font-weight:600}
|
||||
.metric-fail{color:#dc2626;font-weight:600}
|
||||
.history-total{margin-top:4px;color:#64748b;font-size:12px}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -18,168 +18,32 @@
|
||||
</div>
|
||||
|
||||
<el-row :gutter="16" class="overview-grid">
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card overview-card--primary" shadow="hover">
|
||||
<div class="overview-label">插件总数</div>
|
||||
<div class="overview-value">{% raw %}{{ plugins.length }}{% endraw %}</div>
|
||||
<div class="overview-note">当前已注册插件模块</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card" shadow="hover">
|
||||
<div class="overview-label">运行中</div>
|
||||
<div class="overview-value">{% raw %}{{ runningPluginsCount }}{% endraw %}</div>
|
||||
<div class="overview-note">可正常提供能力的插件</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card" shadow="hover">
|
||||
<div class="overview-label">已停用</div>
|
||||
<div class="overview-value">{% raw %}{{ stoppedPluginsCount }}{% endraw %}</div>
|
||||
<div class="overview-note">待启用或排查状态</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4">
|
||||
<el-col :xs="24" :sm="12" :md="6">
|
||||
<el-card class="overview-card overview-card--soft" shadow="hover">
|
||||
<div class="overview-label">治理告警</div>
|
||||
<div class="overview-value">{% raw %}{{ governanceRiskCount }}{% endraw %}</div>
|
||||
<div class="overview-note">存在配置、依赖或加载风险的插件</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4">
|
||||
<el-card class="overview-card" shadow="hover">
|
||||
<div class="overview-label">执行异常</div>
|
||||
<div class="overview-value">{% raw %}{{ executionRiskCount }}{% endraw %}</div>
|
||||
<div class="overview-note">最近执行失败、超时或进入熔断的插件</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="12" :md="8" :lg="4">
|
||||
<el-card class="overview-card" shadow="hover">
|
||||
<div class="overview-label">熔断中</div>
|
||||
<div class="overview-value">{% raw %}{{ openCircuitCount }}{% endraw %}</div>
|
||||
<div class="overview-note">当前被保护机制隔离的高风险插件</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="insight-grid">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>高风险插件</h3>
|
||||
<p>优先排查熔断中、连续失败或最近错误较多的插件。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-list">
|
||||
<div v-if="topRiskPlugins.length === 0" class="mobile-empty-state">暂无高风险插件</div>
|
||||
<div v-for="(plugin, index) in topRiskPlugins" :key="`risk-${plugin.module_name}`" class="rank-item">
|
||||
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
|
||||
<div class="rank-item__content">
|
||||
<div class="rank-item__title-row">
|
||||
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
|
||||
<el-tag :type="executionTagType((plugin.execution_summary || {}).status)" size="mini">
|
||||
{% raw %}{{ executionLabel((plugin.execution_summary || {}).status) }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="rank-item__summary">{% raw %}{{ (plugin.execution_summary || {}).summary || '暂无执行摘要' }}{% endraw %}</div>
|
||||
<div class="rank-item__meta">
|
||||
<span>最近错误:{% raw %}{{ (plugin.execution_summary || {}).last_error_message || '无' }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>慢插件排行</h3>
|
||||
<p>基于最近一次执行耗时,快速定位可能影响主链路响应的插件。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-list">
|
||||
<div v-if="slowestPlugins.length === 0" class="mobile-empty-state">暂无执行样本</div>
|
||||
<div v-for="(plugin, index) in slowestPlugins" :key="`slow-${plugin.module_name}`" class="rank-item">
|
||||
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
|
||||
<div class="rank-item__content">
|
||||
<div class="rank-item__title-row">
|
||||
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
|
||||
<div class="rank-item__value">{% raw %}{{ formatDurationMs((plugin.execution_summary || {}).last_process_time_ms) }}{% endraw %}</div>
|
||||
</div>
|
||||
<div class="rank-item__summary">{% raw %}{{ (plugin.execution_summary || {}).summary || '暂无执行摘要' }}{% endraw %}</div>
|
||||
<div class="rank-item__meta">
|
||||
<span>成功率:{% raw %}{{ formatPercent((plugin.execution_summary || {}).success_rate) }}{% endraw %}</span>
|
||||
<span>累计执行:{% raw %}{{ (plugin.execution_summary || {}).total_executions || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="16" class="insight-grid">
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>依赖核心插件</h3>
|
||||
<p>优先保护被多个插件依赖的基础能力节点,避免单点异常扩散。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-list">
|
||||
<div v-if="topDependencyCorePlugins.length === 0" class="mobile-empty-state">暂无依赖关系数据</div>
|
||||
<div v-for="(plugin, index) in topDependencyCorePlugins" :key="`core-${plugin.module_name}`" class="rank-item">
|
||||
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
|
||||
<div class="rank-item__content">
|
||||
<div class="rank-item__title-row">
|
||||
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
|
||||
<el-tag :type="governanceTagType(plugin.governance_status)" size="mini">
|
||||
{% raw %}{{ `${(plugin.dependency_summary || {}).dependent_count || 0} 个上游` }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="rank-item__summary">
|
||||
{% raw %}{{ buildDependencyCoreSummary(plugin) }}{% endraw %}
|
||||
</div>
|
||||
<div class="rank-item__meta">
|
||||
<span>执行:{% raw %}{{ executionLabel((plugin.execution_summary || {}).status) }}{% endraw %}</span>
|
||||
<span>治理:{% raw %}{{ governanceLabel(plugin.governance_status) }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :md="12">
|
||||
<el-card class="workspace-card" shadow="hover">
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>缺失依赖风险</h3>
|
||||
<p>快速查看声明了依赖但当前目标未加载的插件,优先处理运行链断裂问题。</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rank-list">
|
||||
<div v-if="pluginsWithMissingDependencies.length === 0" class="mobile-empty-state">当前没有缺失依赖风险</div>
|
||||
<div v-for="(plugin, index) in pluginsWithMissingDependencies" :key="`missing-${plugin.module_name}`" class="rank-item">
|
||||
<div class="rank-item__index">{% raw %}{{ index + 1 }}{% endraw %}</div>
|
||||
<div class="rank-item__content">
|
||||
<div class="rank-item__title-row">
|
||||
<div class="rank-item__title">{% raw %}{{ plugin.name }}{% endraw %}</div>
|
||||
<el-tag type="warning" size="mini">
|
||||
{% raw %}{{ `${(plugin.dependency_summary || {}).missing_count || 0} 个缺失` }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="rank-item__summary">
|
||||
{% raw %}{{ buildMissingDependencySummary(plugin) }}{% endraw %}
|
||||
</div>
|
||||
<div class="rank-item__meta">
|
||||
<span>模块:{% raw %}{{ plugin.module_name }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="overview-label">作者数量</div>
|
||||
<div class="overview-value">{% raw %}{{ authorsCount }}{% endraw %}</div>
|
||||
<div class="overview-note">参与维护的作者规模</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
@@ -188,7 +52,7 @@
|
||||
<div slot="header" class="workspace-header">
|
||||
<div>
|
||||
<h3>插件列表</h3>
|
||||
<p>优先关注状态、执行表现和说明,再进入单个插件详情与配置编辑。</p>
|
||||
<p>优先关注状态和说明,再进入单个插件详情与配置编辑。</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -210,59 +74,11 @@
|
||||
<el-table-column prop="description" label="描述" min-width="280" show-overflow-tooltip></el-table-column>
|
||||
<el-table-column label="状态" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="pluginStatusTagType(scope.row.status)">
|
||||
{% raw %}{{ pluginStatusLabel(scope.row) }}{% endraw %}
|
||||
<el-tag :type="scope.row.status === 'RUNNING' ? 'success' : 'info'">
|
||||
{% raw %}{{ scope.row.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="治理健康" width="170" align="center">
|
||||
<template slot-scope="scope">
|
||||
<div class="governance-cell">
|
||||
<el-tag :type="governanceTagType(scope.row.governance_status)" size="small">
|
||||
{% raw %}{{ governanceLabel(scope.row.governance_status) }}{% endraw %}
|
||||
</el-tag>
|
||||
<div class="governance-note">
|
||||
{% raw %}{{ governanceIssueSummary(scope.row) }}{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="执行表现" min-width="220">
|
||||
<template slot-scope="scope">
|
||||
<div class="execution-cell">
|
||||
<div class="execution-cell__head">
|
||||
<el-tag :type="executionTagType((scope.row.execution_summary || {}).status)" size="mini">
|
||||
{% raw %}{{ executionLabel((scope.row.execution_summary || {}).status) }}{% endraw %}
|
||||
</el-tag>
|
||||
<span class="execution-cell__metric">
|
||||
{% raw %}{{ `${formatPercent((scope.row.execution_summary || {}).success_rate)} / ${formatDurationMs((scope.row.execution_summary || {}).last_process_time_ms)}` }}{% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
<div class="execution-cell__summary">
|
||||
{% raw %}{{ (scope.row.execution_summary || {}).summary || '暂无执行摘要' }}{% endraw %}
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="能力类型" width="150" align="center">
|
||||
<template slot-scope="scope">
|
||||
<div class="command-tags command-tags--compact">
|
||||
<el-tag v-for="pluginType in (scope.row.plugin_types || [])" :key="pluginType" size="mini" effect="plain">
|
||||
{% raw %}{{ pluginType }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="命令 / 权限" min-width="180">
|
||||
<template slot-scope="scope">
|
||||
<div class="entity-subtitle">
|
||||
{% raw %}{{ scope.row.command_count ? `命令 ${scope.row.command_count} 个` : '无命令声明' }}{% endraw %}
|
||||
</div>
|
||||
<div class="entity-subtitle">
|
||||
{% raw %}{{ scope.row.feature_key ? `Feature: ${scope.row.feature_key}` : '未接入群级权限' }}{% endraw %}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="290">
|
||||
<template slot-scope="scope">
|
||||
<div class="action-row">
|
||||
@@ -302,29 +118,17 @@
|
||||
<div class="entity-subtitle">模块:{% raw %}{{ plugin.module_name }}{% endraw %}</div>
|
||||
</div>
|
||||
</div>
|
||||
<el-tag :type="pluginStatusTagType(plugin.status)" size="small">
|
||||
{% raw %}{{ pluginStatusLabel(plugin) }}{% endraw %}
|
||||
<el-tag :type="plugin.status === 'RUNNING' ? 'success' : 'info'" size="small">
|
||||
{% raw %}{{ plugin.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div class="mobile-plugin-card__meta">
|
||||
<span>版本:{% raw %}{{ plugin.version || '未知' }}{% endraw %}</span>
|
||||
<span>治理:{% raw %}{{ governanceLabel(plugin.governance_status) }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="mobile-plugin-card__meta">
|
||||
<span>执行:{% raw %}{{ executionLabel((plugin.execution_summary || {}).status) }}{% endraw %}</span>
|
||||
<span>成功率:{% raw %}{{ formatPercent((plugin.execution_summary || {}).success_rate) }}{% endraw %}</span>
|
||||
<span>耗时:{% raw %}{{ formatDurationMs((plugin.execution_summary || {}).last_process_time_ms) }}{% endraw %}</span>
|
||||
<span>作者:{% raw %}{{ plugin.author || '未知' }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="mobile-plugin-card__desc">
|
||||
{% raw %}{{ plugin.description || '暂无描述' }}{% endraw %}
|
||||
</div>
|
||||
<div class="mobile-plugin-card__meta">
|
||||
<span>{% raw %}{{ governanceIssueSummary(plugin) }}{% endraw %}</span>
|
||||
<span>{% raw %}{{ plugin.feature_key ? `Feature: ${plugin.feature_key}` : '未接入群级权限' }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="mobile-plugin-card__meta">
|
||||
<span>依赖:{% raw %}{{ buildDependencySummaryText(plugin) }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="mobile-plugin-card__actions">
|
||||
<el-button
|
||||
size="mini"
|
||||
@@ -355,89 +159,14 @@
|
||||
<el-descriptions-item label="版本" :span="1">{% raw %}{{ selectedPlugin.version }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="作者" :span="1">{% raw %}{{ selectedPlugin.author }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态" :span="1">
|
||||
<el-tag :type="pluginStatusTagType(selectedPlugin.status)" size="small">
|
||||
{% raw %}{{ pluginStatusLabel(selectedPlugin) }}{% endraw %}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="治理健康" :span="1">
|
||||
<el-tag :type="governanceTagType(selectedPlugin.governance_status)" size="small">
|
||||
{% raw %}{{ governanceLabel(selectedPlugin.governance_status) }}{% endraw %}
|
||||
<el-tag :type="selectedPlugin.status === 'RUNNING' ? 'success' : 'info'" size="small">
|
||||
{% raw %}{{ selectedPlugin.status === 'RUNNING' ? '已启用' : '已禁用' }}{% endraw %}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="命令前缀" :span="1" v-if="selectedPlugin.command_prefix !== undefined">
|
||||
{% raw %}{{ selectedPlugin.command_prefix || '无' }}{% endraw %}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="描述" :span="2">{% raw %}{{ selectedPlugin.description }}{% endraw %}</el-descriptions-item>
|
||||
<el-descriptions-item label="能力类型" :span="2" v-if="selectedPlugin.plugin_types && selectedPlugin.plugin_types.length > 0">
|
||||
<div class="command-tags">
|
||||
<el-tag v-for="pluginType in selectedPlugin.plugin_types" :key="pluginType" size="mini" effect="plain">
|
||||
{% raw %}{{ pluginType }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="Feature Key" :span="1">
|
||||
{% raw %}{{ selectedPlugin.feature_key || '未声明' }}{% endraw %}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="群级开关" :span="1">
|
||||
{% raw %}{{ selectedPlugin.supports_group_switch ? '支持' : '未接入' }}{% endraw %}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="依赖插件" :span="2">
|
||||
<div v-if="selectedPlugin.dependencies && selectedPlugin.dependencies.length > 0" class="command-tags">
|
||||
<el-tag v-for="dependency in selectedPlugin.dependencies" :key="dependency" size="mini" effect="plain">
|
||||
{% raw %}{{ dependency }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<span v-else>无</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="依赖关系" :span="2" v-if="selectedPlugin.dependency_summary">
|
||||
<div class="config-overview-grid">
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">声明依赖</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.declared_count || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">已解析依赖</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.resolved_count || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">缺失依赖</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.missing_count || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">下游依赖</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.dependency_summary.dependent_count || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dependency-panels">
|
||||
<div class="dependency-panel">
|
||||
<div class="dependency-panel__title">已解析依赖</div>
|
||||
<div v-if="selectedPlugin.resolved_dependencies && selectedPlugin.resolved_dependencies.length > 0" class="command-tags">
|
||||
<el-tag v-for="dependency in selectedPlugin.resolved_dependencies" :key="`resolved-${dependency.module_name}`" size="mini" effect="plain">
|
||||
{% raw %}{{ `${dependency.name} (${dependency.status_label || dependency.status || '未知'})` }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="entity-subtitle">无</div>
|
||||
</div>
|
||||
<div class="dependency-panel">
|
||||
<div class="dependency-panel__title">缺失依赖</div>
|
||||
<div v-if="selectedPlugin.missing_dependencies && selectedPlugin.missing_dependencies.length > 0" class="command-tags">
|
||||
<el-tag v-for="dependency in selectedPlugin.missing_dependencies" :key="`missing-${dependency.name}`" size="mini" type="warning">
|
||||
{% raw %}{{ dependency.name }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="entity-subtitle">无</div>
|
||||
</div>
|
||||
<div class="dependency-panel">
|
||||
<div class="dependency-panel__title">下游依赖插件</div>
|
||||
<div v-if="selectedPlugin.dependent_plugins && selectedPlugin.dependent_plugins.length > 0" class="command-tags">
|
||||
<el-tag v-for="dependency in selectedPlugin.dependent_plugins" :key="`dependent-${dependency.module_name}`" size="mini" type="success" effect="plain">
|
||||
{% raw %}{{ `${dependency.name} (${dependency.status_label || dependency.status || '未知'})` }}{% endraw %}
|
||||
</el-tag>
|
||||
</div>
|
||||
<div v-else class="entity-subtitle">无</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="命令列表" :span="2" v-if="selectedPlugin.commands && selectedPlugin.commands.length > 0">
|
||||
<div class="command-tags">
|
||||
<el-tag v-for="cmd in selectedPlugin.commands" :key="cmd" size="mini" class="command-tag">
|
||||
@@ -445,105 +174,6 @@
|
||||
</el-tag>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置概览" :span="2" v-if="selectedPlugin.config_overview">
|
||||
<div class="config-overview-grid">
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">配置文件</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.exists ? '存在' : '缺失' }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">解析状态</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.parse_ok ? '正常' : '失败' }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">配置分组</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.section_count || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">敏感字段</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.config_overview.sensitive_field_count || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-subtitle" style="margin-top: 8px;">
|
||||
{% raw %}{{ selectedPlugin.config_path || '未声明配置路径' }}{% endraw %}
|
||||
</div>
|
||||
<div class="entity-subtitle" v-if="selectedPlugin.config_overview.parse_error">
|
||||
{% raw %}{{ `解析错误:${selectedPlugin.config_overview.parse_error}` }}{% endraw %}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行保护" :span="2" v-if="selectedPlugin.execution_guard">
|
||||
<div class="config-overview-grid">
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">熔断状态</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_guard.circuit_state || 'closed' }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">连续失败</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_guard.consecutive_failures || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">连续超时</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_guard.consecutive_timeouts || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">恢复剩余</span>
|
||||
<span class="config-overview-value">{% raw %}{{ `${selectedPlugin.execution_guard.open_remaining_seconds || 0}s` }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="执行表现" :span="2" v-if="selectedPlugin.execution_summary">
|
||||
<div class="config-overview-grid">
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">执行状态</span>
|
||||
<span class="config-overview-value">{% raw %}{{ executionLabel(selectedPlugin.execution_summary.status) }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">累计执行</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.total_executions || 0 }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">成功率</span>
|
||||
<span class="config-overview-value">{% raw %}{{ formatPercent(selectedPlugin.execution_summary.success_rate) }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">超时率</span>
|
||||
<span class="config-overview-value">{% raw %}{{ formatPercent(selectedPlugin.execution_summary.timeout_rate) }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">最近耗时</span>
|
||||
<span class="config-overview-value">{% raw %}{{ formatDurationMs(selectedPlugin.execution_summary.last_process_time_ms) }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">最近成功</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.last_success_at_text || '-' }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">最近失败</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.last_failure_at_text || '-' }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="config-overview-item">
|
||||
<span class="config-overview-label">最近错误</span>
|
||||
<span class="config-overview-value">{% raw %}{{ selectedPlugin.execution_summary.last_error_message || '无' }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="entity-subtitle" style="margin-top: 8px;">
|
||||
{% raw %}{{ selectedPlugin.execution_summary.summary || '暂无执行摘要' }}{% endraw %}
|
||||
</div>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="治理诊断" :span="2" v-if="selectedPlugin.governance_diagnostics">
|
||||
<div v-if="selectedPlugin.governance_diagnostics.length > 0" class="diagnostic-list">
|
||||
<div
|
||||
v-for="(diagnostic, index) in selectedPlugin.governance_diagnostics"
|
||||
:key="`${diagnostic.code}-${index}`"
|
||||
class="diagnostic-item">
|
||||
<el-tag :type="governanceTagType(diagnostic.level)" size="mini">
|
||||
{% raw %}{{ governanceLabel(diagnostic.level) }}{% endraw %}
|
||||
</el-tag>
|
||||
<span class="diagnostic-text">{% raw %}{{ diagnostic.message }}{% endraw %}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span v-else>暂无治理诊断项</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置信息" :span="2" v-if="selectedPlugin.config">
|
||||
<div class="config-container">
|
||||
<div class="config-actions">
|
||||
@@ -740,75 +370,8 @@
|
||||
stoppedPluginsCount() {
|
||||
return this.plugins.filter(plugin => plugin.status !== 'RUNNING').length;
|
||||
},
|
||||
governanceRiskCount() {
|
||||
return (this.plugins || []).filter(plugin => ['warning', 'error'].includes((plugin.governance_status || '').toLowerCase())).length;
|
||||
},
|
||||
executionRiskCount() {
|
||||
// 这里把执行风险单独统计出来,和治理告警区分开:
|
||||
// 治理告警偏配置/依赖/加载问题,执行风险偏运行过程中的失败、超时与熔断。
|
||||
return (this.plugins || []).filter(plugin => ['warning', 'error'].includes((((plugin.execution_summary || {}).status) || '').toLowerCase())).length;
|
||||
},
|
||||
openCircuitCount() {
|
||||
return (this.plugins || []).filter(plugin => ((((plugin.execution_summary || {}).circuit_state) || '').toLowerCase() === 'open')).length;
|
||||
},
|
||||
topRiskPlugins() {
|
||||
// 风险排行优先按熔断状态、执行状态和连续失败次数排序,
|
||||
// 让页面顶部尽量把“最值得先排查”的插件顶上来。
|
||||
const statusPriority = {
|
||||
error: 0,
|
||||
warning: 1,
|
||||
info: 2,
|
||||
healthy: 3
|
||||
};
|
||||
return (this.plugins || [])
|
||||
.filter(plugin => ['warning', 'error'].includes((((plugin.execution_summary || {}).status) || '').toLowerCase()))
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
const leftSummary = left.execution_summary || {};
|
||||
const rightSummary = right.execution_summary || {};
|
||||
const leftPriority = statusPriority[(leftSummary.status || 'info').toLowerCase()];
|
||||
const rightPriority = statusPriority[(rightSummary.status || 'info').toLowerCase()];
|
||||
return (
|
||||
(typeof leftPriority === 'number' ? leftPriority : 9) - (typeof rightPriority === 'number' ? rightPriority : 9)
|
||||
|| Number(rightSummary.consecutive_failures || 0) - Number(leftSummary.consecutive_failures || 0)
|
||||
|| Number(rightSummary.failure_count_total || 0) - Number(leftSummary.failure_count_total || 0)
|
||||
|| Number(rightSummary.timeout_count_total || 0) - Number(leftSummary.timeout_count_total || 0)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
},
|
||||
topDependencyCorePlugins() {
|
||||
// 核心依赖插件优先按“被多少插件依赖”排序,
|
||||
// 这样最容易形成单点影响的基础插件会排在前面。
|
||||
return (this.plugins || [])
|
||||
.filter(plugin => Number(((plugin.dependency_summary || {}).dependent_count) || 0) > 0)
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
return (
|
||||
Number(((right.dependency_summary || {}).dependent_count) || 0) - Number(((left.dependency_summary || {}).dependent_count) || 0)
|
||||
|| Number(((right.dependency_summary || {}).declared_count) || 0) - Number(((left.dependency_summary || {}).declared_count) || 0)
|
||||
);
|
||||
})
|
||||
.slice(0, 5);
|
||||
},
|
||||
pluginsWithMissingDependencies() {
|
||||
return (this.plugins || [])
|
||||
.filter(plugin => Number(((plugin.dependency_summary || {}).missing_count) || 0) > 0)
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
return Number(((right.dependency_summary || {}).missing_count) || 0) - Number(((left.dependency_summary || {}).missing_count) || 0);
|
||||
})
|
||||
.slice(0, 5);
|
||||
},
|
||||
slowestPlugins() {
|
||||
// 慢插件排行只看有执行样本的插件,避免未执行插件把榜单冲掉。
|
||||
return (this.plugins || [])
|
||||
.filter(plugin => Number((plugin.execution_summary || {}).total_executions || 0) > 0)
|
||||
.slice()
|
||||
.sort((left, right) => {
|
||||
return Number((right.execution_summary || {}).last_process_time_ms || 0) - Number((left.execution_summary || {}).last_process_time_ms || 0);
|
||||
})
|
||||
.slice(0, 5);
|
||||
authorsCount() {
|
||||
return new Set((this.plugins || []).map(plugin => plugin.author).filter(Boolean)).size;
|
||||
},
|
||||
// 弹窗宽度按视口分级收缩,保证手机上弹窗内容不会贴边或继续触发横向溢出。
|
||||
pluginInfoDialogWidth() {
|
||||
@@ -837,109 +400,6 @@
|
||||
// 这里统一以 768px 作为移动端断点,和常见后台管理布局断点保持一致。
|
||||
this.isMobileViewport = window.innerWidth <= 768;
|
||||
},
|
||||
pluginStatusTagType(status) {
|
||||
const normalizedStatus = String(status || '').toUpperCase();
|
||||
if (normalizedStatus === 'RUNNING') return 'success';
|
||||
if (normalizedStatus === 'ERROR') return 'danger';
|
||||
if (normalizedStatus === 'LOADED') return 'warning';
|
||||
return 'info';
|
||||
},
|
||||
pluginStatusLabel(plugin) {
|
||||
if (plugin && plugin.status_label) return plugin.status_label;
|
||||
const normalizedStatus = String((plugin && plugin.status) || '').toUpperCase();
|
||||
const mapping = {
|
||||
RUNNING: '运行中',
|
||||
STOPPED: '已停用',
|
||||
LOADED: '已加载',
|
||||
UNLOADED: '未加载',
|
||||
ERROR: '异常',
|
||||
DISCOVERED: '待处理'
|
||||
};
|
||||
return mapping[normalizedStatus] || '未知';
|
||||
},
|
||||
governanceTagType(level) {
|
||||
const normalizedLevel = String(level || '').toLowerCase();
|
||||
if (normalizedLevel === 'error') return 'danger';
|
||||
if (normalizedLevel === 'warning') return 'warning';
|
||||
if (normalizedLevel === 'healthy') return 'success';
|
||||
return 'info';
|
||||
},
|
||||
governanceLabel(level) {
|
||||
const normalizedLevel = String(level || '').toLowerCase();
|
||||
const mapping = {
|
||||
healthy: '健康',
|
||||
warning: '告警',
|
||||
error: '异常',
|
||||
info: '提示'
|
||||
};
|
||||
return mapping[normalizedLevel] || '提示';
|
||||
},
|
||||
executionTagType(level) {
|
||||
const normalizedLevel = String(level || '').toLowerCase();
|
||||
if (normalizedLevel === 'error') return 'danger';
|
||||
if (normalizedLevel === 'warning') return 'warning';
|
||||
if (normalizedLevel === 'healthy') return 'success';
|
||||
return 'info';
|
||||
},
|
||||
executionLabel(level) {
|
||||
const normalizedLevel = String(level || '').toLowerCase();
|
||||
const mapping = {
|
||||
healthy: '稳定',
|
||||
warning: '需关注',
|
||||
error: '高风险',
|
||||
info: '暂无样本'
|
||||
};
|
||||
return mapping[normalizedLevel] || '暂无样本';
|
||||
},
|
||||
governanceIssueSummary(plugin) {
|
||||
const errorCount = Number((plugin && plugin.governance_error_count) || 0);
|
||||
const warningCount = Number((plugin && plugin.governance_warning_count) || 0);
|
||||
const infoCount = Number((plugin && plugin.governance_info_count) || 0);
|
||||
if (errorCount > 0 || warningCount > 0) {
|
||||
return `错误 ${errorCount} / 告警 ${warningCount}`;
|
||||
}
|
||||
if (infoCount > 0) {
|
||||
return `提示 ${infoCount} 项`;
|
||||
}
|
||||
return '暂无治理问题';
|
||||
},
|
||||
formatPercent(value) {
|
||||
const normalizedValue = Number(value || 0);
|
||||
if (!Number.isFinite(normalizedValue)) return '0.00%';
|
||||
return `${normalizedValue.toFixed(2)}%`;
|
||||
},
|
||||
formatDurationMs(value) {
|
||||
const normalizedValue = Number(value || 0);
|
||||
if (!Number.isFinite(normalizedValue) || normalizedValue <= 0) return '-';
|
||||
return `${normalizedValue.toFixed(2)} ms`;
|
||||
},
|
||||
buildDependencySummaryText(plugin) {
|
||||
const dependencySummary = (plugin && plugin.dependency_summary) || {};
|
||||
const declaredCount = Number(dependencySummary.declared_count || 0);
|
||||
const missingCount = Number(dependencySummary.missing_count || 0);
|
||||
const dependentCount = Number(dependencySummary.dependent_count || 0);
|
||||
if (declaredCount <= 0 && dependentCount <= 0) {
|
||||
return '无依赖关系';
|
||||
}
|
||||
if (missingCount > 0) {
|
||||
return `声明 ${declaredCount} 个,缺失 ${missingCount} 个`;
|
||||
}
|
||||
if (dependentCount > 0) {
|
||||
return `被 ${dependentCount} 个插件依赖`;
|
||||
}
|
||||
return `已解析 ${declaredCount} 个依赖`;
|
||||
},
|
||||
buildDependencyCoreSummary(plugin) {
|
||||
const dependencySummary = (plugin && plugin.dependency_summary) || {};
|
||||
return `当前被 ${(dependencySummary.dependent_count || 0)} 个插件依赖,自身声明 ${(dependencySummary.declared_count || 0)} 个依赖。`;
|
||||
},
|
||||
buildMissingDependencySummary(plugin) {
|
||||
const missingDependencies = ((plugin && plugin.missing_dependencies) || []).map(item => item.name).filter(Boolean);
|
||||
if (!missingDependencies.length) {
|
||||
return '当前没有缺失依赖。';
|
||||
}
|
||||
return `缺失依赖:${missingDependencies.join('、')}`;
|
||||
},
|
||||
loadPlugins() {
|
||||
this.loading = true;
|
||||
axios.get('/api/plugins')
|
||||
@@ -1027,6 +487,7 @@
|
||||
},
|
||||
saveConfig() {
|
||||
try {
|
||||
let configObj;
|
||||
axios.post('/api/plugins/config/update', {
|
||||
plugin_name: this.selectedPlugin.module_name,
|
||||
config_text: this.editedConfig,
|
||||
@@ -1037,11 +498,7 @@
|
||||
this.$message.success('配置保存成功');
|
||||
this.isEditingConfig = false;
|
||||
this.selectedPlugin.configText = this.editedConfig;
|
||||
// 保存成功后立即重新拉取详情:
|
||||
// 1. 同步刷新治理诊断、配置概览和内存中的插件配置快照;
|
||||
// 2. 避免页面上继续停留在旧的健康状态;
|
||||
// 3. 这样后续是否重载插件,用户都能先看到“配置文本已通过校验并落盘”。
|
||||
this.showPluginInfo(this.selectedPlugin);
|
||||
this.selectedPlugin.config = configObj;
|
||||
this.$confirm('配置已保存,是否要重载插件以应用新配置?', '提示', {
|
||||
confirmButtonText: '重载插件',
|
||||
cancelButtonText: '稍后手动重载',
|
||||
@@ -1057,7 +514,7 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('保存配置出错:', error);
|
||||
this.configError = '保存配置出错: ' + (((error.response || {}).data || {}).message || error.message);
|
||||
this.configError = '保存配置出错: ' + (error.response?.data?.message || error.message);
|
||||
});
|
||||
} catch (e) {
|
||||
this.configError = '处理配置时出错: ' + e.message;
|
||||
@@ -1224,7 +681,6 @@
|
||||
font-size: 14px;
|
||||
}
|
||||
.overview-grid .el-col { margin-bottom: 16px; }
|
||||
.insight-grid .el-col { margin-bottom: 16px; }
|
||||
.overview-card { min-height: 112px; }
|
||||
.overview-card--primary {
|
||||
background: linear-gradient(180deg, rgba(79,70,229,0.10), rgba(255,255,255,0.94)) !important;
|
||||
@@ -1240,64 +696,6 @@
|
||||
}
|
||||
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
|
||||
.workspace-header p { font-size: 13px; color: #64748b; }
|
||||
.rank-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.rank-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(248,250,252,0.82);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.rank-item__index {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(79,70,229,0.10);
|
||||
color: #4f46e5;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rank-item__content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.rank-item__title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.rank-item__title,
|
||||
.rank-item__value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
.rank-item__summary {
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: #475569;
|
||||
word-break: break-word;
|
||||
}
|
||||
.rank-item__meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 14px;
|
||||
margin-top: 8px;
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
}
|
||||
.entity-cell { display: flex; align-items: center; gap: 12px; }
|
||||
.entity-badge {
|
||||
width: 30px; height: 30px; border-radius: 50%; display: inline-flex; align-items: center;
|
||||
@@ -1316,106 +714,10 @@
|
||||
}
|
||||
.config-container pre { margin: 0; white-space: pre-wrap; word-break: break-word; }
|
||||
.command-tags { display: flex; flex-wrap: wrap; gap: 6px; }
|
||||
.command-tags--compact { justify-content: center; }
|
||||
.command-tag { margin: 0 !important; }
|
||||
.config-actions { margin-bottom: 10px; display: flex; gap: 10px; }
|
||||
.config-editor { font-family: monospace; font-size: 12px; }
|
||||
.config-error { color: #ef4444; font-size: 12px; margin-top: 5px; }
|
||||
.governance-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
.governance-note {
|
||||
font-size: 11px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.execution-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.execution-cell__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.execution-cell__metric {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
font-weight: 600;
|
||||
}
|
||||
.execution-cell__summary {
|
||||
font-size: 12px;
|
||||
color: #94a3b8;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.config-overview-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.dependency-panels {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
.dependency-panel {
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
background: rgba(248,250,252,0.82);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.dependency-panel__title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: #334155;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.config-overview-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.config-overview-label {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
.config-overview-value {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #0f172a;
|
||||
}
|
||||
.diagnostic-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.diagnostic-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
background: rgba(248,250,252,0.82);
|
||||
border: 1px solid rgba(148,163,184,0.12);
|
||||
}
|
||||
.diagnostic-text {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
color: #334155;
|
||||
line-height: 1.6;
|
||||
word-break: break-word;
|
||||
}
|
||||
.plugin-group-status-dialog { min-height: 240px; }
|
||||
.mobile-plugin-list,
|
||||
.mobile-group-list {
|
||||
@@ -1548,10 +850,6 @@
|
||||
.mobile-plugin-card__header {
|
||||
flex-direction: column;
|
||||
}
|
||||
.rank-item__title-row {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.mobile-plugin-card__actions .el-button,
|
||||
.mobile-group-card__actions .el-button {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
|
||||
@@ -33,28 +33,6 @@
|
||||
<el-tag :type="statusTag(scope.row.last_status)">{% raw %}{{ scope.row.last_status || 'never' }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="健康状态" width="120" align="center">
|
||||
<template slot-scope="scope">
|
||||
<el-tag :type="healthTag(scope.row.health_status)">{% raw %}{{ healthLabel(scope.row.health_status) }}{% endraw %}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="latest_success_at" label="最近成功" min-width="170"></el-table-column>
|
||||
<el-table-column label="最近失败原因" min-width="240">
|
||||
<template slot-scope="scope">
|
||||
<div class="cell-ellipsis" :title="scope.row.latest_failure_summary || scope.row.last_error || '-'">
|
||||
{% raw %}{{ scope.row.latest_failure_summary || scope.row.last_error || '-' }}{% endraw %}
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="历史执行" width="150" align="center">
|
||||
<template slot-scope="scope">
|
||||
<div class="history-metrics">
|
||||
<span class="metric-success">{% raw %}{{ `成 ${scope.row.history_success_count || 0}` }}{% endraw %}</span>
|
||||
<span class="metric-fail">{% raw %}{{ `失 ${scope.row.history_fail_count || 0}` }}{% endraw %}</span>
|
||||
</div>
|
||||
<div class="history-total">{% raw %}{{ `共 ${scope.row.history_total_count || 0}` }}{% endraw %}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="280">
|
||||
<template slot-scope="scope">
|
||||
<div class="action-row">
|
||||
@@ -165,23 +143,6 @@ new Vue({
|
||||
if (status === 'running') return 'warning';
|
||||
return 'info';
|
||||
},
|
||||
healthTag(status) {
|
||||
if (status === 'healthy') return 'success';
|
||||
if (status === 'running') return 'warning';
|
||||
if (status === 'failed') return 'danger';
|
||||
if (status === 'disabled') return 'info';
|
||||
return '';
|
||||
},
|
||||
healthLabel(status) {
|
||||
const mapping = {
|
||||
healthy: '健康',
|
||||
running: '执行中',
|
||||
failed: '异常',
|
||||
disabled: '停用',
|
||||
idle: '待运行'
|
||||
};
|
||||
return mapping[status] || '待运行';
|
||||
},
|
||||
async loadJobs() {
|
||||
this.loading = true;
|
||||
try {
|
||||
@@ -308,10 +269,5 @@ new Vue({
|
||||
.page-hero-copy h1{font-size:30px;line-height:1.1;margin-bottom:10px;color:#0f172a}
|
||||
.page-hero-copy p{color:#64748b;font-size:14px}
|
||||
.action-row{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
|
||||
.cell-ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:#475569}
|
||||
.history-metrics{display:flex;align-items:center;justify-content:center;gap:8px}
|
||||
.metric-success{color:#16a34a;font-weight:600}
|
||||
.metric-fail{color:#dc2626;font-weight:600}
|
||||
.history-total{margin-top:4px;color:#64748b;font-size:12px}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
70
db/base.py
70
db/base.py
@@ -1,6 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import time
|
||||
|
||||
from loguru import logger
|
||||
from typing import List, Dict, Any, Optional, Tuple, Union
|
||||
|
||||
@@ -14,62 +12,19 @@ class BaseDBOperator:
|
||||
self.db_manager = db_manager
|
||||
self.LOG = logger
|
||||
|
||||
@staticmethod
|
||||
def _compact_sql(sql: str) -> str:
|
||||
"""把 SQL 压成单行,便于日志里快速定位问题。"""
|
||||
return " ".join(str(sql or "").split())
|
||||
|
||||
@classmethod
|
||||
def _truncate_text(cls, value, max_length: int = 240) -> str:
|
||||
"""截断长文本,避免日志被超长 SQL 或参数刷屏。"""
|
||||
text = str(value or "")
|
||||
if len(text) <= max_length:
|
||||
return text
|
||||
return f"{text[:max_length]}..."
|
||||
|
||||
def _log_sql_timing(self, operation: str, sql: str, params, elapsed_ms: float, affected_rows: Optional[int] = None) -> None:
|
||||
"""记录慢 SQL 日志。
|
||||
|
||||
设计说明:
|
||||
1. 只在超过阈值时输出 warning,避免日常日志噪声过大;
|
||||
2. 统一输出压缩后的 SQL 与截断参数,便于线上排查具体慢点;
|
||||
3. 查询/更新/批量/事务都走同一入口,后续如果要接后台审计也更容易扩展。
|
||||
"""
|
||||
if not self.db_manager.is_slow_query_log_enabled():
|
||||
return
|
||||
|
||||
threshold_ms = self.db_manager.get_slow_query_threshold_ms()
|
||||
if elapsed_ms < threshold_ms:
|
||||
return
|
||||
|
||||
affected_text = ""
|
||||
if affected_rows is not None:
|
||||
affected_text = f" affected_rows={affected_rows}"
|
||||
self.LOG.warning(
|
||||
f"检测到慢SQL operation={operation} cost_ms={round(elapsed_ms, 2)} threshold_ms={threshold_ms}"
|
||||
f"{affected_text} sql={self._truncate_text(self._compact_sql(sql), 400)} "
|
||||
f"params={self._truncate_text(params, 240)}"
|
||||
)
|
||||
|
||||
def execute_query(self, sql: str, params: Optional[tuple] = None, fetch_one: bool = False) -> Union[
|
||||
List[Dict], Dict, None]:
|
||||
"""执行查询SQL"""
|
||||
conn = self.db_manager.get_mysql_connection()
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
with conn.cursor(dictionary=True) as cursor:
|
||||
cursor.execute(sql, params or ())
|
||||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||||
if fetch_one:
|
||||
result = cursor.fetchone()
|
||||
self._log_sql_timing("query_one", sql, params, elapsed_ms, 1 if result else 0)
|
||||
return result
|
||||
result = cursor.fetchall()
|
||||
self._log_sql_timing("query", sql, params, elapsed_ms, len(result or []))
|
||||
return result
|
||||
return cursor.fetchone()
|
||||
return cursor.fetchall()
|
||||
except Exception as e:
|
||||
self.LOG.error(
|
||||
f"执行查询SQL出错: {e}, SQL: {sql}, 参数: {str(params)[:200] + '...' if len(str(params)) > 200 else params}"
|
||||
f"执行更新SQL出错: {e}, SQL: {sql}, 参数: {str(params)[:200] + '...' if len(str(params)) > 200 else params}"
|
||||
)
|
||||
return None
|
||||
finally:
|
||||
@@ -78,13 +33,10 @@ class BaseDBOperator:
|
||||
def execute_update(self, sql: str, params: Optional[tuple] = None) -> bool:
|
||||
"""执行更新SQL"""
|
||||
conn = self.db_manager.get_mysql_connection()
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.execute(sql, params or ())
|
||||
affected_rows = cursor.rowcount
|
||||
conn.commit()
|
||||
self._log_sql_timing("update", sql, params, (time.perf_counter() - started_at) * 1000, affected_rows)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.LOG.error(
|
||||
@@ -101,19 +53,10 @@ class BaseDBOperator:
|
||||
return True
|
||||
|
||||
conn = self.db_manager.get_mysql_connection()
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
cursor.executemany(sql, params_list)
|
||||
affected_rows = cursor.rowcount
|
||||
conn.commit()
|
||||
self._log_sql_timing(
|
||||
"batch_update",
|
||||
sql,
|
||||
f"params_count={len(params_list)}",
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
affected_rows,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.LOG.error(f"批量执行SQL出错: {e}, SQL: {sql}, 参数数量: {len(params_list)}")
|
||||
@@ -128,18 +71,11 @@ class BaseDBOperator:
|
||||
return True
|
||||
|
||||
conn = self.db_manager.get_mysql_connection()
|
||||
started_at = time.perf_counter()
|
||||
try:
|
||||
with conn.cursor() as cursor:
|
||||
for sql, params in operations:
|
||||
cursor.execute(sql, params)
|
||||
conn.commit()
|
||||
self._log_sql_timing(
|
||||
"transaction",
|
||||
f"{len(operations)} statements",
|
||||
f"operations={len(operations)}",
|
||||
(time.perf_counter() - started_at) * 1000,
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
self.LOG.error(f"执行事务出错: {e}, 操作数量: {len(operations)}")
|
||||
|
||||
@@ -39,13 +39,7 @@ class DBConnectionManager:
|
||||
self.LOG = logger
|
||||
self.mysql_pool = None
|
||||
self.redis_pool = None
|
||||
# 保存原始配置快照,供慢 SQL 阈值、库名探测等公共能力复用:
|
||||
# 1. BaseDBOperator 需要读取数据库名,去 information_schema 中检查索引;
|
||||
# 2. 慢 SQL 记录需要统一读取阈值配置,而不是每个 DB Operator 各自硬编码;
|
||||
# 3. 这里做浅拷贝即可,避免后续外部修改传入 dict 时影响内部状态。
|
||||
self.mysql_config = dict(mysql_config or {})
|
||||
self.redis_config = dict(redis_config or {})
|
||||
|
||||
|
||||
# 初始化MySQL连接池
|
||||
if mysql_config:
|
||||
self.init_mysql_pool(mysql_config)
|
||||
@@ -64,8 +58,6 @@ class DBConnectionManager:
|
||||
if not config:
|
||||
self.LOG.warning("MySQL配置为空,跳过初始化")
|
||||
return
|
||||
|
||||
self.mysql_config = dict(config or {})
|
||||
|
||||
# 准备连接池配置
|
||||
pool_config = {
|
||||
@@ -98,8 +90,6 @@ class DBConnectionManager:
|
||||
if not config:
|
||||
self.LOG.warning("Redis配置为空,跳过初始化")
|
||||
return
|
||||
|
||||
self.redis_config = dict(config or {})
|
||||
|
||||
self.redis_pool = redis.ConnectionPool(
|
||||
host=config.get('host', 'localhost'),
|
||||
@@ -127,26 +117,6 @@ class DBConnectionManager:
|
||||
raise Exception("MySQL连接池未初始化")
|
||||
|
||||
return self.mysql_pool.get_connection()
|
||||
|
||||
def get_mysql_database_name(self) -> str:
|
||||
"""返回当前 MySQL 目标库名。"""
|
||||
return str(self.mysql_config.get('database', '') or '').strip()
|
||||
|
||||
def get_slow_query_threshold_ms(self) -> int:
|
||||
"""读取慢 SQL 阈值,默认 500ms。"""
|
||||
try:
|
||||
threshold = int(self.mysql_config.get('slow_query_threshold_ms', 500) or 500)
|
||||
return threshold if threshold > 0 else 500
|
||||
except (TypeError, ValueError):
|
||||
return 500
|
||||
|
||||
def is_slow_query_log_enabled(self) -> bool:
|
||||
"""是否启用慢 SQL 日志。"""
|
||||
raw_value = self.mysql_config.get('enable_slow_query_log', True)
|
||||
if isinstance(raw_value, str):
|
||||
normalized = raw_value.strip().lower()
|
||||
return normalized not in {'0', 'false', 'off', 'no'}
|
||||
return bool(raw_value)
|
||||
|
||||
def get_redis_connection(self):
|
||||
"""获取Redis连接
|
||||
@@ -170,4 +140,4 @@ class DBConnectionManager:
|
||||
# 关闭Redis连接池
|
||||
if self.redis_pool:
|
||||
self.redis_pool.disconnect()
|
||||
self.redis_pool = None
|
||||
self.redis_pool = None
|
||||
@@ -1,8 +1,7 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import datetime
|
||||
import json
|
||||
from threading import Lock
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
from db.base import BaseDBOperator
|
||||
@@ -13,103 +12,8 @@ from wechat_ipad.models.message import WxMessage
|
||||
class MessageStorageDB(BaseDBOperator):
|
||||
"""消息存储相关数据库操作"""
|
||||
|
||||
_performance_ready = False
|
||||
_performance_lock = Lock()
|
||||
|
||||
def __init__(self, db_manager: DBConnectionManager):
|
||||
super().__init__(db_manager)
|
||||
self._ensure_performance_primitives()
|
||||
|
||||
@staticmethod
|
||||
def _normalize_datetime_text(value) -> str:
|
||||
"""把日期/时间对象统一转成数据库可比较的标准字符串。"""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
return str(value or "").strip()
|
||||
|
||||
@classmethod
|
||||
def _build_day_time_range(cls, target_date: str) -> tuple[str, str]:
|
||||
"""把 `YYYY-MM-DD` 日期转换成 `[00:00:00, 次日00:00:00)` 时间范围。"""
|
||||
start_dt = datetime.strptime(str(target_date or "").strip(), "%Y-%m-%d")
|
||||
end_dt = start_dt.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
next_day_dt = end_dt + timedelta(days=1)
|
||||
return (
|
||||
end_dt.strftime("%Y-%m-%d 00:00:00"),
|
||||
next_day_dt.strftime("%Y-%m-%d 00:00:00"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _build_day_bounds(cls, start_date: str, end_date: str) -> tuple[str, str]:
|
||||
"""把日期区间转换成适合索引命中的时间范围。"""
|
||||
start_dt = datetime.strptime(str(start_date or "").strip(), "%Y-%m-%d")
|
||||
end_dt = datetime.strptime(str(end_date or "").strip(), "%Y-%m-%d")
|
||||
if end_dt < start_dt:
|
||||
start_dt, end_dt = end_dt, start_dt
|
||||
next_day_dt = end_dt + timedelta(days=1)
|
||||
return (
|
||||
start_dt.strftime("%Y-%m-%d 00:00:00"),
|
||||
next_day_dt.strftime("%Y-%m-%d 00:00:00"),
|
||||
)
|
||||
|
||||
def _ensure_performance_primitives(self) -> None:
|
||||
"""确保消息存储相关的关键索引存在。
|
||||
|
||||
设计说明:
|
||||
1. 这一步只补“高频查询明确受益”的索引,不做激进表结构重写;
|
||||
2. 使用 information_schema 做存在性检查,保证重复启动时仍然幂等;
|
||||
3. 只在进程内执行一次,避免每次 new MessageStorageDB 都重复打元数据查询。
|
||||
"""
|
||||
if self.__class__._performance_ready:
|
||||
return
|
||||
|
||||
with self.__class__._performance_lock:
|
||||
if self.__class__._performance_ready:
|
||||
return
|
||||
|
||||
self._ensure_index_exists(
|
||||
table_name="messages",
|
||||
index_name="idx_group_sender_timestamp",
|
||||
create_sql="CREATE INDEX idx_group_sender_timestamp ON messages (group_id, sender, timestamp)",
|
||||
)
|
||||
self._ensure_index_exists(
|
||||
table_name="messages",
|
||||
index_name="idx_group_type_timestamp",
|
||||
create_sql="CREATE INDEX idx_group_type_timestamp ON messages (group_id, message_type, timestamp)",
|
||||
)
|
||||
self._ensure_index_exists(
|
||||
table_name="messages",
|
||||
index_name="idx_media_pending_lookup",
|
||||
create_sql="CREATE INDEX idx_media_pending_lookup ON messages (message_type, image_path, timestamp, group_id)",
|
||||
)
|
||||
self.__class__._performance_ready = True
|
||||
|
||||
def _ensure_index_exists(self, table_name: str, index_name: str, create_sql: str) -> None:
|
||||
"""按需补建单个索引。"""
|
||||
database_name = self.db_manager.get_mysql_database_name()
|
||||
if not database_name:
|
||||
return
|
||||
|
||||
existing = self.execute_query(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM information_schema.statistics
|
||||
WHERE table_schema = %s
|
||||
AND table_name = %s
|
||||
AND index_name = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(database_name, table_name, index_name),
|
||||
fetch_one=True,
|
||||
)
|
||||
if existing:
|
||||
return
|
||||
|
||||
# 索引补建属于“性能自愈”动作:
|
||||
# 1. 不要求用户手工跑 migration,服务启动时可自动补齐;
|
||||
# 2. 若线上库字段类型和预期不一致,失败后只记日志,不阻断主流程;
|
||||
# 3. 这样先拿到可观测收益,再决定后续是否做更完整的 schema migration。
|
||||
if not self.execute_update(create_sql):
|
||||
self.LOG.warning(f"消息表索引补建失败,请人工检查: table={table_name}, index={index_name}")
|
||||
|
||||
def archive_message(self, msg: WxMessage) -> bool:
|
||||
"""存档消息
|
||||
@@ -348,12 +252,10 @@ class MessageStorageDB(BaseDBOperator):
|
||||
|
||||
def get_member_messages_on_date(self, group_id: str, wxid: str, target_date: str, limit: int = 120) -> List[Dict]:
|
||||
"""获取成员在某一天的消息"""
|
||||
start_time, end_time = self._build_day_time_range(target_date)
|
||||
sql = """
|
||||
SELECT timestamp, sender, content, message_type
|
||||
FROM messages
|
||||
WHERE timestamp >= %s
|
||||
AND timestamp < %s
|
||||
WHERE DATE(timestamp) = %s
|
||||
AND group_id = %s
|
||||
AND sender = %s
|
||||
AND message_type IN (1, 49)
|
||||
@@ -362,16 +264,14 @@ class MessageStorageDB(BaseDBOperator):
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
return self.execute_query(sql, (start_time, end_time, group_id, wxid, limit)) or []
|
||||
return self.execute_query(sql, (target_date, group_id, wxid, limit)) or []
|
||||
|
||||
def get_member_messages_for_group_date(self, group_id: str, target_date: str, limit: int = 5000) -> List[Dict]:
|
||||
"""获取群在某一天的全部文本消息"""
|
||||
start_time, end_time = self._build_day_time_range(target_date)
|
||||
sql = """
|
||||
SELECT timestamp, sender, content, message_type
|
||||
FROM messages
|
||||
WHERE timestamp >= %s
|
||||
AND timestamp < %s
|
||||
WHERE DATE(timestamp) = %s
|
||||
AND group_id = %s
|
||||
AND sender IS NOT NULL
|
||||
AND sender <> ''
|
||||
@@ -381,7 +281,7 @@ class MessageStorageDB(BaseDBOperator):
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
return self.execute_query(sql, (start_time, end_time, group_id, limit)) or []
|
||||
return self.execute_query(sql, (target_date, group_id, limit)) or []
|
||||
|
||||
def get_recent_group_chat_messages(self, group_id: str, limit: int = 20) -> List[Dict]:
|
||||
"""获取群聊最近消息"""
|
||||
@@ -415,15 +315,13 @@ class MessageStorageDB(BaseDBOperator):
|
||||
|
||||
def get_message_count_by_date(self, date: str) -> List[Dict]:
|
||||
"""获取指定日期的消息统计"""
|
||||
start_time, end_time = self._build_day_time_range(date)
|
||||
sql = """
|
||||
SELECT group_id, sender, COUNT(*) as count
|
||||
FROM messages
|
||||
WHERE timestamp >= %s
|
||||
AND timestamp < %s
|
||||
WHERE DATE(timestamp) = %s
|
||||
GROUP BY group_id, sender
|
||||
"""
|
||||
return self.execute_query(sql, (start_time, end_time)) or []
|
||||
return self.execute_query(sql, (date,)) or []
|
||||
|
||||
def get_speech_ranking(self, date: str, group_id: str, limit: int = 20) -> List[Dict]:
|
||||
"""获取指定日期和群组的发言排名"""
|
||||
@@ -582,19 +480,14 @@ class MessageStorageDB(BaseDBOperator):
|
||||
params.append(group_id)
|
||||
|
||||
if start_date:
|
||||
start_bound = f"{str(start_date).strip()} 00:00:00"
|
||||
sql_count += " AND timestamp >= %s "
|
||||
sql_data += " AND timestamp >= %s "
|
||||
params.append(start_bound)
|
||||
sql_count += " AND DATE(timestamp) >= %s "
|
||||
sql_data += " AND DATE(timestamp) >= %s "
|
||||
params.append(start_date)
|
||||
|
||||
if end_date:
|
||||
_, end_bound = self._build_day_bounds(
|
||||
start_date or str(end_date).strip(),
|
||||
str(end_date).strip(),
|
||||
)
|
||||
sql_count += " AND timestamp < %s "
|
||||
sql_data += " AND timestamp < %s "
|
||||
params.append(end_bound)
|
||||
sql_count += " AND DATE(timestamp) <= %s "
|
||||
sql_data += " AND DATE(timestamp) <= %s "
|
||||
params.append(end_date)
|
||||
|
||||
if search_text:
|
||||
sql_count += " AND content LIKE %s "
|
||||
@@ -772,8 +665,8 @@ class MessageStorageDB(BaseDBOperator):
|
||||
"""
|
||||
return self.execute_query(sql, (f'%md5="{md5}"%',), fetch_one=True)
|
||||
|
||||
def get_messages_by_calendar_range(self, group_id: str, start_date: str, end_date: str = None,
|
||||
min_content_length: int = 6, max_results: int = 5000) -> List[Dict]:
|
||||
def get_messages_by_date_range(self, group_id: str, start_date: str, end_date: str = None,
|
||||
min_content_length: int = 6, max_results: int = 5000) -> List[Dict]:
|
||||
"""按日期范围获取消息(支持按天总结)
|
||||
|
||||
Args:
|
||||
@@ -789,13 +682,11 @@ class MessageStorageDB(BaseDBOperator):
|
||||
if end_date is None:
|
||||
end_date = start_date
|
||||
|
||||
start_time, end_time = self._build_day_bounds(start_date, end_date)
|
||||
|
||||
sql = """
|
||||
SELECT timestamp, sender, content, message_type
|
||||
FROM messages
|
||||
WHERE timestamp >= %s
|
||||
AND timestamp < %s
|
||||
WHERE DATE(timestamp) >= %s
|
||||
AND DATE(timestamp) <= %s
|
||||
AND group_id = %s
|
||||
AND message_type IN (1, 49)
|
||||
AND LENGTH(content) > %s
|
||||
@@ -804,7 +695,7 @@ class MessageStorageDB(BaseDBOperator):
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT %s
|
||||
"""
|
||||
params = (start_time, end_time, group_id, min_content_length, max_results)
|
||||
params = (start_date, end_date, group_id, min_content_length, max_results)
|
||||
return self.execute_query(sql, params) or []
|
||||
|
||||
def get_messages_for_summary(self, group_id: str, hours_ago: int = 8,
|
||||
@@ -858,8 +749,8 @@ class MessageStorageDB(BaseDBOperator):
|
||||
AND content NOT LIKE '/%'
|
||||
ORDER BY timestamp ASC
|
||||
"""
|
||||
params = (self._normalize_datetime_text(start_time),
|
||||
self._normalize_datetime_text(end_time),
|
||||
params = (start_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
end_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
group_id)
|
||||
return self.execute_query(sql, params) or []
|
||||
|
||||
@@ -885,8 +776,8 @@ class MessageStorageDB(BaseDBOperator):
|
||||
AND CHAR_LENGTH(content) < 300
|
||||
AND content NOT LIKE '/%'
|
||||
"""
|
||||
params = (self._normalize_datetime_text(start_time),
|
||||
self._normalize_datetime_text(end_time),
|
||||
params = (start_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
end_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
group_id)
|
||||
result = self.execute_query(sql, params)
|
||||
return result[0]['count'] if result else 0
|
||||
@@ -910,8 +801,8 @@ class MessageStorageDB(BaseDBOperator):
|
||||
AND sender <> ''
|
||||
"""
|
||||
params = (
|
||||
self._normalize_datetime_text(start_time),
|
||||
self._normalize_datetime_text(end_time),
|
||||
start_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
end_time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
group_id,
|
||||
)
|
||||
result = self.execute_query(sql, params, fetch_one=True) or {}
|
||||
|
||||
@@ -216,22 +216,6 @@ class PluginScheduleDBOperator(BaseDBOperator):
|
||||
) or {}
|
||||
return row.get("triggered_at")
|
||||
|
||||
@staticmethod
|
||||
def _clean_schedule_ids(schedule_ids: List[int]) -> List[int]:
|
||||
"""清洗批量查询用的调度 ID 列表。"""
|
||||
clean_ids: List[int] = []
|
||||
seen = set()
|
||||
for item in schedule_ids or []:
|
||||
text = str(item or "").strip()
|
||||
if not text.isdigit():
|
||||
continue
|
||||
schedule_id = int(text)
|
||||
if schedule_id in seen:
|
||||
continue
|
||||
clean_ids.append(schedule_id)
|
||||
seen.add(schedule_id)
|
||||
return clean_ids
|
||||
|
||||
def get_latest_logs_map(self, schedule_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||||
"""批量获取每个调度任务最新一条执行日志。
|
||||
|
||||
@@ -240,7 +224,7 @@ class PluginScheduleDBOperator(BaseDBOperator):
|
||||
2. 进程重启后,async_job 的运行时计数会重置,但数据库日志仍完整;
|
||||
3. 这里提供批量查询接口,让上层可用日志数据兜底回填展示字段。
|
||||
"""
|
||||
clean_ids = self._clean_schedule_ids(schedule_ids)
|
||||
clean_ids = [int(x) for x in schedule_ids if str(x).strip().isdigit()]
|
||||
if not clean_ids:
|
||||
return {}
|
||||
|
||||
@@ -263,83 +247,3 @@ class PluginScheduleDBOperator(BaseDBOperator):
|
||||
if schedule_id > 0:
|
||||
result[schedule_id] = row
|
||||
return result
|
||||
|
||||
def get_schedule_history_summary_map(self, schedule_ids: List[int]) -> Dict[int, Dict[str, Any]]:
|
||||
"""批量汇总调度任务的历史执行摘要。"""
|
||||
clean_ids = self._clean_schedule_ids(schedule_ids)
|
||||
if not clean_ids:
|
||||
return {}
|
||||
|
||||
placeholders = ",".join(["%s"] * len(clean_ids))
|
||||
summary_sql = f"""
|
||||
SELECT
|
||||
schedule_id,
|
||||
MAX(CASE WHEN status = 'success' THEN triggered_at ELSE NULL END) AS latest_success_at,
|
||||
MAX(CASE WHEN status = 'failed' THEN triggered_at ELSE NULL END) AS latest_failed_at,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS fail_count,
|
||||
COUNT(*) AS total_count
|
||||
FROM t_plugin_schedule_logs
|
||||
WHERE schedule_id IN ({placeholders})
|
||||
GROUP BY schedule_id
|
||||
"""
|
||||
latest_failed_sql = f"""
|
||||
SELECT l.*
|
||||
FROM t_plugin_schedule_logs l
|
||||
INNER JOIN (
|
||||
SELECT schedule_id, MAX(id) AS max_id
|
||||
FROM t_plugin_schedule_logs
|
||||
WHERE status = 'failed' AND schedule_id IN ({placeholders})
|
||||
GROUP BY schedule_id
|
||||
) t ON l.id = t.max_id
|
||||
"""
|
||||
|
||||
summary_rows = self.execute_query(summary_sql, tuple(clean_ids)) or []
|
||||
latest_failed_rows = self.execute_query(latest_failed_sql, tuple(clean_ids)) or []
|
||||
|
||||
result: Dict[int, Dict[str, Any]] = {}
|
||||
for row in summary_rows:
|
||||
schedule_id = int(row.get("schedule_id") or 0)
|
||||
if schedule_id <= 0:
|
||||
continue
|
||||
result[schedule_id] = {
|
||||
"latest_success_at": row.get("latest_success_at"),
|
||||
"latest_failed_at": row.get("latest_failed_at"),
|
||||
"latest_failure_summary": "",
|
||||
"latest_failure_detail": {},
|
||||
"history_success_count": int(row.get("success_count") or 0),
|
||||
"history_fail_count": int(row.get("fail_count") or 0),
|
||||
"history_total_count": int(row.get("total_count") or 0),
|
||||
}
|
||||
|
||||
for row in latest_failed_rows:
|
||||
schedule_id = int(row.get("schedule_id") or 0)
|
||||
if schedule_id <= 0:
|
||||
continue
|
||||
|
||||
detail = row.get("detail_json")
|
||||
if isinstance(detail, str):
|
||||
try:
|
||||
detail = json.loads(detail)
|
||||
except json.JSONDecodeError:
|
||||
detail = {}
|
||||
elif detail is None:
|
||||
detail = {}
|
||||
|
||||
history = result.setdefault(
|
||||
schedule_id,
|
||||
{
|
||||
"latest_success_at": None,
|
||||
"latest_failed_at": row.get("triggered_at"),
|
||||
"latest_failure_summary": "",
|
||||
"latest_failure_detail": {},
|
||||
"history_success_count": 0,
|
||||
"history_fail_count": 0,
|
||||
"history_total_count": 0,
|
||||
},
|
||||
)
|
||||
history["latest_failed_at"] = row.get("triggered_at")
|
||||
history["latest_failure_summary"] = str(row.get("summary") or "").strip()
|
||||
history["latest_failure_detail"] = detail or {}
|
||||
|
||||
return result
|
||||
|
||||
@@ -52,12 +52,6 @@ create or replace index idx_date_timestamp
|
||||
create or replace index idx_group_timestamp
|
||||
on message_archive.messages (group_id, timestamp);
|
||||
|
||||
create or replace index idx_group_sender_timestamp
|
||||
on message_archive.messages (group_id, sender, timestamp);
|
||||
|
||||
create or replace index idx_group_type_timestamp
|
||||
on message_archive.messages (group_id, message_type, timestamp);
|
||||
|
||||
create or replace index idx_message_sender
|
||||
on message_archive.messages (sender);
|
||||
|
||||
@@ -67,9 +61,6 @@ create or replace index idx_message_type
|
||||
create or replace index messages_message_id_index
|
||||
on message_archive.messages (message_id);
|
||||
|
||||
create or replace index idx_media_pending_lookup
|
||||
on message_archive.messages (message_type, image_path, timestamp, group_id);
|
||||
|
||||
create or replace table message_archive.t_emoji_assets
|
||||
(
|
||||
md5 varchar(64) not null comment '表情MD5'
|
||||
|
||||
@@ -171,145 +171,6 @@ class SystemJobDBOperator(BaseDBOperator):
|
||||
row["detail_json"] = {}
|
||||
return rows
|
||||
|
||||
@staticmethod
|
||||
def _clean_job_keys(job_keys: List[str]) -> List[str]:
|
||||
"""清洗批量查询用的任务 key 列表。
|
||||
|
||||
设计说明:
|
||||
1. 后台列表页会一次性请求多个任务的历史摘要,必须先去掉空值和重复值;
|
||||
2. 统一在 DB Operator 层做清洗,避免上层每个调用方都重复写一遍;
|
||||
3. 保持输入顺序,便于后续排查时能和原始列表一一对应。
|
||||
"""
|
||||
clean_keys: List[str] = []
|
||||
seen = set()
|
||||
for item in job_keys or []:
|
||||
key = str(item or "").strip()
|
||||
if not key or key in seen:
|
||||
continue
|
||||
clean_keys.append(key)
|
||||
seen.add(key)
|
||||
return clean_keys
|
||||
|
||||
def get_latest_logs_map(self, job_keys: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""批量读取每个任务最新一条执行日志。"""
|
||||
clean_keys = self._clean_job_keys(job_keys)
|
||||
if not clean_keys:
|
||||
return {}
|
||||
|
||||
placeholders = ",".join(["%s"] * len(clean_keys))
|
||||
sql = f"""
|
||||
SELECT l.*
|
||||
FROM t_system_job_logs l
|
||||
INNER JOIN (
|
||||
SELECT job_key, MAX(id) AS max_id
|
||||
FROM t_system_job_logs
|
||||
WHERE job_key IN ({placeholders})
|
||||
GROUP BY job_key
|
||||
) t ON l.id = t.max_id
|
||||
"""
|
||||
rows = self.execute_query(sql, tuple(clean_keys)) or []
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
for row in rows:
|
||||
detail = row.get("detail_json")
|
||||
if isinstance(detail, str):
|
||||
try:
|
||||
row["detail_json"] = json.loads(detail)
|
||||
except json.JSONDecodeError:
|
||||
row["detail_json"] = {}
|
||||
elif detail is None:
|
||||
row["detail_json"] = {}
|
||||
|
||||
job_key = str(row.get("job_key") or "").strip()
|
||||
if job_key:
|
||||
result[job_key] = row
|
||||
return result
|
||||
|
||||
def get_job_history_summary_map(self, job_keys: List[str]) -> Dict[str, Dict[str, Any]]:
|
||||
"""批量汇总系统任务的执行历史摘要。
|
||||
|
||||
返回字段覆盖后台最常用的问题定位视角:
|
||||
1. 最近成功时间,便于判断任务是否长期没有跑通;
|
||||
2. 最近失败时间与失败摘要,便于列表页直接看到异常原因;
|
||||
3. 累计成功/失败/总执行次数,便于粗看任务稳定性。
|
||||
"""
|
||||
clean_keys = self._clean_job_keys(job_keys)
|
||||
if not clean_keys:
|
||||
return {}
|
||||
|
||||
placeholders = ",".join(["%s"] * len(clean_keys))
|
||||
summary_sql = f"""
|
||||
SELECT
|
||||
job_key,
|
||||
MAX(CASE WHEN status = 'success' THEN triggered_at ELSE NULL END) AS latest_success_at,
|
||||
MAX(CASE WHEN status = 'failed' THEN triggered_at ELSE NULL END) AS latest_failed_at,
|
||||
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success_count,
|
||||
SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS fail_count,
|
||||
COUNT(*) AS total_count
|
||||
FROM t_system_job_logs
|
||||
WHERE job_key IN ({placeholders})
|
||||
GROUP BY job_key
|
||||
"""
|
||||
latest_failed_sql = f"""
|
||||
SELECT l.*
|
||||
FROM t_system_job_logs l
|
||||
INNER JOIN (
|
||||
SELECT job_key, MAX(id) AS max_id
|
||||
FROM t_system_job_logs
|
||||
WHERE status = 'failed' AND job_key IN ({placeholders})
|
||||
GROUP BY job_key
|
||||
) t ON l.id = t.max_id
|
||||
"""
|
||||
|
||||
summary_rows = self.execute_query(summary_sql, tuple(clean_keys)) or []
|
||||
latest_failed_rows = self.execute_query(latest_failed_sql, tuple(clean_keys)) or []
|
||||
|
||||
result: Dict[str, Dict[str, Any]] = {}
|
||||
for row in summary_rows:
|
||||
job_key = str(row.get("job_key") or "").strip()
|
||||
if not job_key:
|
||||
continue
|
||||
result[job_key] = {
|
||||
"latest_success_at": row.get("latest_success_at"),
|
||||
"latest_failed_at": row.get("latest_failed_at"),
|
||||
"latest_failure_summary": "",
|
||||
"latest_failure_detail": {},
|
||||
"history_success_count": int(row.get("success_count") or 0),
|
||||
"history_fail_count": int(row.get("fail_count") or 0),
|
||||
"history_total_count": int(row.get("total_count") or 0),
|
||||
}
|
||||
|
||||
for row in latest_failed_rows:
|
||||
job_key = str(row.get("job_key") or "").strip()
|
||||
if not job_key:
|
||||
continue
|
||||
|
||||
detail = row.get("detail_json")
|
||||
if isinstance(detail, str):
|
||||
try:
|
||||
detail = json.loads(detail)
|
||||
except json.JSONDecodeError:
|
||||
detail = {}
|
||||
elif detail is None:
|
||||
detail = {}
|
||||
|
||||
history = result.setdefault(
|
||||
job_key,
|
||||
{
|
||||
"latest_success_at": None,
|
||||
"latest_failed_at": row.get("triggered_at"),
|
||||
"latest_failure_summary": "",
|
||||
"latest_failure_detail": {},
|
||||
"history_success_count": 0,
|
||||
"history_fail_count": 0,
|
||||
"history_total_count": 0,
|
||||
},
|
||||
)
|
||||
history["latest_failed_at"] = row.get("triggered_at")
|
||||
history["latest_failure_summary"] = str(row.get("summary") or "").strip()
|
||||
history["latest_failure_detail"] = detail or {}
|
||||
|
||||
return result
|
||||
|
||||
def get_latest_log_time(self, job_key: str) -> Optional[datetime]:
|
||||
"""获取任务最新一次执行日志时间。"""
|
||||
row = self.execute_query(
|
||||
|
||||
@@ -400,15 +400,6 @@
|
||||
|
||||
- 把插件系统从“可加载”升级为“可治理”
|
||||
|
||||
当前进展:
|
||||
|
||||
- 第一阶段已完成:`PluginManager` 已输出统一插件治理快照,后台不再只展示“加载成功的插件”
|
||||
- 第一阶段已完成:后台插件管理页已补充治理健康、能力类型、Feature Key、依赖与配置概览信息
|
||||
- 第一阶段已完成:插件配置保存前已增加格式校验,避免坏配置直接写回线上文件
|
||||
- 第二阶段已完成:插件管理页已补充执行表现摘要、最近错误信息与高风险/慢插件排行,便于快速定位运行异常插件
|
||||
- 第二阶段已完成:插件快照已补充依赖拓扑摘要,后台可直接查看核心依赖插件、缺失依赖风险与上下游关系
|
||||
- 后续可继续补充插件错误历史、性能排名、依赖图与熔断/隔离控制
|
||||
|
||||
建议内容:
|
||||
|
||||
- 插件元信息页面
|
||||
@@ -435,13 +426,6 @@
|
||||
|
||||
- 防止单插件问题拖垮整体系统
|
||||
|
||||
当前进展:
|
||||
|
||||
- 第一阶段已完成:消息插件执行已增加统一超时保护,避免单插件长时间卡住主链路
|
||||
- 第一阶段已完成:已补充连续失败熔断、冷却后半开探测与自动恢复逻辑
|
||||
- 第一阶段已完成:插件治理快照与后台详情已可查看执行保护状态、连续失败与恢复剩余时间
|
||||
- 后续可继续补充插件级并发配额、失败原因聚合、后台手动解除熔断与更细粒度的隔离策略
|
||||
|
||||
建议内容:
|
||||
|
||||
- 插件处理超时控制
|
||||
@@ -465,13 +449,6 @@
|
||||
|
||||
- 让定时任务真正可管理、可追踪
|
||||
|
||||
当前进展:
|
||||
|
||||
- 第一阶段已完成:系统任务页与插件调度页已补充历史执行摘要,可直接查看最近成功时间、最近失败原因与累计成功/失败次数
|
||||
- 第一阶段已完成:任务列表接口已合并内存运行态与数据库日志态,服务重启后后台仍可回看最近执行结果
|
||||
- 第一阶段已完成:插件调度页已补充快捷启停入口,减少仅为切换启用状态而进入编辑弹窗的操作成本
|
||||
- 后续可继续补充任务执行审计人、失败重试策略模板、筛选搜索与跨任务汇总看板
|
||||
|
||||
建议内容:
|
||||
|
||||
- 展示任务执行历史
|
||||
@@ -499,14 +476,6 @@
|
||||
|
||||
- 提高高消息量场景下的吞吐与查询效率
|
||||
|
||||
当前进展:
|
||||
|
||||
- 第一阶段已完成:数据库公共层已增加慢 SQL 记录能力,可按 `db_config.slow_query_threshold_ms` 阈值输出慢查询日志
|
||||
- 第一阶段已完成:消息存储层启动时会自动补齐关键查询索引,优先覆盖群消息范围查询、成员消息回溯与待处理媒体扫描场景
|
||||
- 第一阶段已完成:多处按日期查询已改为时间范围查询,避免 `DATE(timestamp)` 直接作用在索引列上导致索引失效
|
||||
- 第一阶段已完成:已修正消息存储层重复定义的日期范围方法,避免按天汇总查询误走错误实现
|
||||
- 后续可继续补充统计报表快照表、Redis key 扫描替换方案、后台慢 SQL 看板与更多统计表索引治理
|
||||
|
||||
建议内容:
|
||||
|
||||
- 梳理消息表与统计表索引
|
||||
@@ -587,13 +556,6 @@
|
||||
|
||||
- 降低普通用户与管理员的使用门槛
|
||||
|
||||
当前进展:
|
||||
|
||||
- 第一阶段已完成:`菜单 指令清单 / 功能清单 / 命令清单 / 帮助` 已改为基于运行中插件快照自动生成
|
||||
- 第一阶段已完成:指令清单已按当前群真实可用状态过滤,管理员可额外看到未启用命令与管理命令
|
||||
- 第二阶段已完成:后台已新增“命令索引”页面,可按群查看真实可用命令、未启用命令、自动能力与管理员触发示例
|
||||
- 后续可继续补充插件触发示例模板、命令分类标签与更细粒度的使用说明
|
||||
|
||||
建议内容:
|
||||
|
||||
- 自动生成按插件分类的帮助菜单
|
||||
|
||||
@@ -20,7 +20,7 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
|
||||
# 功能权限常量
|
||||
FEATURE_KEY = "ROBOT_MENU"
|
||||
FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 | 菜单 状态 | 菜单 指令清单]"
|
||||
FEATURE_DESCRIPTION = "📋 功能菜单 [菜单 - 显示功能菜单 | 菜单 状态 - 显示功能状态]"
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -263,31 +263,6 @@ class RobotMenuPlugin(MessagePluginInterface):
|
||||
)
|
||||
return True, "显示功能状态"
|
||||
|
||||
if cmd_name in {"指令清单", "功能清单", "命令清单", "帮助"}:
|
||||
# 指令清单改为直接从插件快照自动生成:
|
||||
# 1. 展示当前群“真实可用”的命令,而不是手工维护的固定文案;
|
||||
# 2. 管理员额外看到未启用项与管理命令,普通用户只看到能直接用的内容;
|
||||
# 3. 这样后续新增/删除插件后,菜单无需手动同步修改。
|
||||
command_catalog_text = self.menu_renderer.build_command_catalog_text(
|
||||
roomid if roomid else sender,
|
||||
sender,
|
||||
)
|
||||
command_catalog_markdown = self.menu_renderer.build_command_catalog_markdown(
|
||||
roomid if roomid else sender,
|
||||
sender,
|
||||
)
|
||||
await self.menu_renderer.send_menu_content(
|
||||
bot=bot,
|
||||
target=target,
|
||||
sender=sender,
|
||||
revoke=revoke,
|
||||
text_content=command_catalog_text,
|
||||
markdown_content=command_catalog_markdown,
|
||||
html_content="",
|
||||
revoke_seconds=120,
|
||||
)
|
||||
return True, "显示指令清单"
|
||||
|
||||
# 处理群列表命令
|
||||
if cmd_name.upper() == "群列表":
|
||||
group_list_text = self.get_group_list()
|
||||
|
||||
@@ -7,7 +7,6 @@ from typing import Any, Optional, Tuple
|
||||
|
||||
from loguru import logger as default_logger
|
||||
|
||||
from base.plugin_common.plugin_manager import PluginManager
|
||||
from utils.markdown_to_image import convert_md_str_to_image, html_to_image
|
||||
from utils.revoke.message_auto_revoke import MessageAutoRevoke
|
||||
from utils.robot_cmd.robot_command import Feature, GroupBotManager, PermissionStatus
|
||||
@@ -190,283 +189,6 @@ class RobotMenuRenderTool:
|
||||
},
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_plugin_manager() -> PluginManager:
|
||||
"""获取当前运行中的插件管理器单例。"""
|
||||
return PluginManager.get_instance()
|
||||
|
||||
@staticmethod
|
||||
def _resolve_snapshot_group_status(snapshot: dict, group_id: str) -> dict:
|
||||
"""解析插件在当前群里的可用状态。
|
||||
|
||||
规则说明:
|
||||
1. 插件必须先处于 RUNNING,才可能被认为“可用”;
|
||||
2. 若插件支持群级开关,则继续读取该群的 feature 权限;
|
||||
3. 若插件没有群级开关,则视为“运行即全局可用”。
|
||||
"""
|
||||
normalized_snapshot = dict(snapshot or {})
|
||||
status = str(normalized_snapshot.get("status") or "").strip().upper()
|
||||
supports_group_switch = bool(normalized_snapshot.get("supports_group_switch"))
|
||||
feature_key = str(normalized_snapshot.get("feature_key") or "").strip()
|
||||
|
||||
if status != "RUNNING":
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "插件未运行",
|
||||
"reason_code": "plugin_not_running",
|
||||
}
|
||||
|
||||
if not group_id or not supports_group_switch or not feature_key:
|
||||
return {
|
||||
"available": True,
|
||||
"reason": "全局可用",
|
||||
"reason_code": "global_available",
|
||||
}
|
||||
|
||||
feature = Feature.get_feature(feature_key)
|
||||
if feature is None:
|
||||
return {
|
||||
"available": True,
|
||||
"reason": "未绑定群级开关,按运行中处理",
|
||||
"reason_code": "feature_not_registered",
|
||||
}
|
||||
|
||||
permission = GroupBotManager.get_group_permission(group_id, feature)
|
||||
if permission == PermissionStatus.ENABLED:
|
||||
return {
|
||||
"available": True,
|
||||
"reason": "本群已启用",
|
||||
"reason_code": "group_enabled",
|
||||
}
|
||||
return {
|
||||
"available": False,
|
||||
"reason": "本群未启用",
|
||||
"reason_code": "group_disabled",
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _format_plugin_command(example_command: str, command_prefix: str) -> str:
|
||||
"""把插件命令和前缀拼成最终展示文本。"""
|
||||
prefix = str(command_prefix or "").strip()
|
||||
command = str(example_command or "").strip()
|
||||
if not prefix:
|
||||
return command
|
||||
return f"{prefix}{command}"
|
||||
|
||||
def _build_plugin_command_entry(self, snapshot: dict, group_id: str) -> Optional[dict]:
|
||||
"""把插件快照转换为菜单可展示的命令项。"""
|
||||
normalized_snapshot = dict(snapshot or {})
|
||||
commands = list(normalized_snapshot.get("commands", []) or [])
|
||||
plugin_types = list(normalized_snapshot.get("plugin_types", []) or [])
|
||||
if not commands and "scheduled" not in plugin_types:
|
||||
return None
|
||||
|
||||
availability = self._resolve_snapshot_group_status(normalized_snapshot, group_id)
|
||||
command_prefix = str(normalized_snapshot.get("command_prefix") or "").strip()
|
||||
primary_command = self._format_plugin_command(commands[0], command_prefix) if commands else ""
|
||||
alias_commands = [
|
||||
self._format_plugin_command(command_text, command_prefix)
|
||||
for command_text in commands[1:4]
|
||||
if str(command_text or "").strip()
|
||||
]
|
||||
|
||||
if "message" in plugin_types:
|
||||
category = "message"
|
||||
category_label = "消息指令"
|
||||
elif "scheduled" in plugin_types:
|
||||
category = "scheduled"
|
||||
category_label = "自动任务"
|
||||
else:
|
||||
category = "generic"
|
||||
category_label = "通用能力"
|
||||
|
||||
return {
|
||||
"name": str(normalized_snapshot.get("name") or "").strip(),
|
||||
"module_name": str(normalized_snapshot.get("module_name") or "").strip(),
|
||||
"description": str(normalized_snapshot.get("description") or "").strip() or "暂无描述",
|
||||
"category": category,
|
||||
"category_label": category_label,
|
||||
"commands": commands,
|
||||
"primary_command": primary_command,
|
||||
"alias_commands": alias_commands,
|
||||
"supports_group_switch": bool(normalized_snapshot.get("supports_group_switch")),
|
||||
"feature_key": str(normalized_snapshot.get("feature_key") or "").strip(),
|
||||
"available": bool(availability.get("available")),
|
||||
"availability_reason": str(availability.get("reason") or "").strip(),
|
||||
"availability_code": str(availability.get("reason_code") or "").strip(),
|
||||
"status_label": str(normalized_snapshot.get("status_label") or "").strip(),
|
||||
}
|
||||
|
||||
def _collect_command_catalog(self, group_id: str, requester_id: str, force_admin: Optional[bool] = None) -> dict:
|
||||
"""采集当前群和当前身份视角下的命令清单。
|
||||
|
||||
输出结构分三层:
|
||||
1. 普通用户可直接用的命令;
|
||||
2. 自动/定时能力;
|
||||
3. 管理员附加能力与未启用项。
|
||||
"""
|
||||
plugin_manager = self._get_plugin_manager()
|
||||
snapshots = plugin_manager.get_plugin_snapshots()
|
||||
if force_admin is None:
|
||||
is_admin = bool(GroupBotManager.is_admin_for_group(requester_id, group_id)) if group_id else bool(GroupBotManager.is_admin(requester_id))
|
||||
else:
|
||||
is_admin = bool(force_admin)
|
||||
|
||||
available_manual = []
|
||||
available_auto = []
|
||||
unavailable_manual = []
|
||||
|
||||
for snapshot in snapshots:
|
||||
entry = self._build_plugin_command_entry(snapshot, group_id)
|
||||
if not entry:
|
||||
continue
|
||||
if entry["category"] == "scheduled":
|
||||
if entry["available"]:
|
||||
available_auto.append(entry)
|
||||
continue
|
||||
if entry["available"]:
|
||||
available_manual.append(entry)
|
||||
else:
|
||||
unavailable_manual.append(entry)
|
||||
|
||||
available_manual.sort(key=lambda item: (item["category"], item["name"], item["primary_command"]))
|
||||
available_auto.sort(key=lambda item: (item["name"], item["primary_command"]))
|
||||
unavailable_manual.sort(key=lambda item: (item["availability_code"], item["name"]))
|
||||
|
||||
admin_commands = []
|
||||
if is_admin:
|
||||
admin_commands = [
|
||||
{"title": "查看功能状态", "example": "菜单 状态", "description": "查看当前群所有功能开关状态"},
|
||||
{"title": "启用某个功能", "example": "菜单 启用 功能序号", "description": "按菜单序号启用某项功能"},
|
||||
{"title": "关闭某个功能", "example": "菜单 关闭 功能序号", "description": "按菜单序号关闭某项功能"},
|
||||
{"title": "查看群管理员", "example": "菜单 管理员 列表", "description": "查看当前群管理员清单"},
|
||||
{"title": "添加群管理员", "example": "菜单 管理员 添加 @某人", "description": "把某个群成员加入本群管理员"},
|
||||
{"title": "删除群管理员", "example": "菜单 管理员 删除 @某人", "description": "移除某个群管理员"},
|
||||
]
|
||||
if GroupBotManager.is_admin(requester_id):
|
||||
admin_commands.append(
|
||||
{"title": "查看托管群列表", "example": "菜单 群列表", "description": "查看所有已启用机器人的群"}
|
||||
)
|
||||
|
||||
return {
|
||||
"group_id": str(group_id or "").strip(),
|
||||
"requester_id": str(requester_id or "").strip(),
|
||||
"is_admin": is_admin,
|
||||
"available_manual": available_manual,
|
||||
"available_auto": available_auto,
|
||||
"unavailable_manual": unavailable_manual,
|
||||
"admin_commands": admin_commands,
|
||||
"generated_at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
}
|
||||
|
||||
def build_command_catalog_data(self, group_id: str, requester_id: str, force_admin: Optional[bool] = None) -> dict:
|
||||
"""对外暴露统一的命令目录结构,供机器人菜单和后台页面共同复用。"""
|
||||
return self._collect_command_catalog(group_id, requester_id, force_admin=force_admin)
|
||||
|
||||
def build_command_catalog_text(self, group_id: str, requester_id: str) -> str:
|
||||
"""构建适合直接发送给用户的文本版命令清单。"""
|
||||
catalog = self.build_command_catalog_data(group_id, requester_id)
|
||||
lines = [
|
||||
"📚 当前群指令清单",
|
||||
f"群ID:{catalog['group_id'] or '私聊'}",
|
||||
f"生成时间:{catalog['generated_at']}",
|
||||
"",
|
||||
"一、当前可直接使用的命令",
|
||||
]
|
||||
|
||||
if catalog["available_manual"]:
|
||||
for item in catalog["available_manual"]:
|
||||
lines.append(f"【{item['name']}】{item['description']}")
|
||||
if item["primary_command"]:
|
||||
lines.append(f"主指令:{item['primary_command']}")
|
||||
if item["alias_commands"]:
|
||||
lines.append(f"别名:{' / '.join(item['alias_commands'])}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("当前没有可直接使用的手动命令")
|
||||
lines.append("")
|
||||
|
||||
lines.append("二、自动/定时能力")
|
||||
if catalog["available_auto"]:
|
||||
for item in catalog["available_auto"]:
|
||||
lines.append(f"【{item['name']}】{item['description']}")
|
||||
lines.append("触发方式:自动或定时运行")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("当前没有已启用的自动能力")
|
||||
lines.append("")
|
||||
|
||||
if catalog["is_admin"]:
|
||||
lines.append("三、管理员额外可见")
|
||||
if catalog["unavailable_manual"]:
|
||||
lines.append("未启用或暂不可用命令:")
|
||||
for item in catalog["unavailable_manual"]:
|
||||
primary = item["primary_command"] or "无手动指令"
|
||||
lines.append(f"- {item['name']}:{primary}({item['availability_reason']})")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("当前没有未启用的命令项")
|
||||
lines.append("")
|
||||
|
||||
lines.append("管理命令:")
|
||||
for item in catalog["admin_commands"]:
|
||||
lines.append(f"- {item['example']}:{item['description']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("提示:发送“菜单”查看功能开关;发送“菜单 状态”查看本群功能状态。")
|
||||
return "\n".join(lines).strip()
|
||||
|
||||
def build_command_catalog_markdown(self, group_id: str, requester_id: str) -> str:
|
||||
"""构建适合图片渲染的 Markdown 版指令清单。"""
|
||||
catalog = self.build_command_catalog_data(group_id, requester_id)
|
||||
lines = [
|
||||
"# 机器人指令清单",
|
||||
"",
|
||||
f"- 目标:`{catalog['group_id'] or '私聊'}`",
|
||||
f"- 生成时间:`{catalog['generated_at']}`",
|
||||
"",
|
||||
"## 当前可直接使用的命令",
|
||||
]
|
||||
|
||||
if catalog["available_manual"]:
|
||||
for item in catalog["available_manual"]:
|
||||
lines.append(f"### {item['name']}")
|
||||
lines.append(f"- 说明:{item['description']}")
|
||||
if item["primary_command"]:
|
||||
lines.append(f"- 主指令:`{item['primary_command']}`")
|
||||
if item["alias_commands"]:
|
||||
alias_text = " / ".join(f"`{alias}`" for alias in item["alias_commands"])
|
||||
lines.append(f"- 别名:{alias_text}")
|
||||
lines.append("")
|
||||
else:
|
||||
lines.append("- 当前没有可直接使用的手动命令")
|
||||
lines.append("")
|
||||
|
||||
lines.append("## 自动/定时能力")
|
||||
if catalog["available_auto"]:
|
||||
for item in catalog["available_auto"]:
|
||||
lines.append(f"- **{item['name']}**:{item['description']}")
|
||||
else:
|
||||
lines.append("- 当前没有已启用的自动能力")
|
||||
lines.append("")
|
||||
|
||||
if catalog["is_admin"]:
|
||||
lines.append("## 管理员额外可见")
|
||||
if catalog["unavailable_manual"]:
|
||||
lines.append("### 未启用或暂不可用命令")
|
||||
for item in catalog["unavailable_manual"]:
|
||||
primary = item["primary_command"] or "无手动指令"
|
||||
lines.append(f"- **{item['name']}**:`{primary}`({item['availability_reason']})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("### 管理命令")
|
||||
for item in catalog["admin_commands"]:
|
||||
lines.append(f"- `{item['example']}`:{item['description']}")
|
||||
lines.append("")
|
||||
|
||||
lines.append("> 提示:发送 `菜单` 查看功能开关;发送 `菜单 状态` 查看本群功能状态。")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def send_menu_content(
|
||||
self,
|
||||
bot: WechatAPIClient,
|
||||
|
||||
138
robot.py
138
robot.py
@@ -649,33 +649,7 @@ class Robot:
|
||||
|
||||
# 检查插件是否可以处理该消息
|
||||
if plugin.can_process(plugin_msg):
|
||||
protection_policy = self._build_message_plugin_protection_policy(plugin)
|
||||
acquire_result = self.plugin_manager.try_acquire_plugin_execution(
|
||||
plugin,
|
||||
recovery_seconds=protection_policy["circuit_recovery_seconds"],
|
||||
)
|
||||
if not acquire_result.get("allowed", False):
|
||||
# 熔断打开或半开探测占用时,这里只跳过当前插件:
|
||||
# 1. 保护目标是避免单插件持续拖慢主链路,而不是直接关闭整个插件;
|
||||
# 2. 后续插件仍然可以继续尝试处理当前消息,降低功能面损失;
|
||||
# 3. 冷却结束后会自动进入半开恢复探测,无需人工介入恢复。
|
||||
self.LOG.warning(
|
||||
self._trace_message(
|
||||
msg,
|
||||
f"插件保护跳过 plugin={plugin.name} reason={acquire_result.get('reason')} "
|
||||
f"remaining={acquire_result.get('open_remaining_seconds', 0)}s"
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
processed, _ = await asyncio.wait_for(
|
||||
plugin.process_message(plugin_msg),
|
||||
timeout=protection_policy["process_timeout_seconds"],
|
||||
)
|
||||
self.plugin_manager.record_plugin_execution_success(
|
||||
plugin,
|
||||
process_time_ms=self._elapsed_ms(started_at),
|
||||
)
|
||||
processed, _ = await plugin.process_message(plugin_msg)
|
||||
self._record_plugin_call_result(
|
||||
plugin=plugin,
|
||||
msg=msg,
|
||||
@@ -696,58 +670,14 @@ class Robot:
|
||||
)
|
||||
)
|
||||
return True
|
||||
except asyncio.TimeoutError as timeout_error:
|
||||
protection_policy = self._build_message_plugin_protection_policy(plugin)
|
||||
failure_record = self.plugin_manager.record_plugin_execution_failure(
|
||||
plugin,
|
||||
failure_type="timeout",
|
||||
error_message=(
|
||||
f"插件执行超时,超过 {protection_policy['process_timeout_seconds']} 秒仍未完成。"
|
||||
),
|
||||
process_time_ms=self._elapsed_ms(started_at),
|
||||
timeout_seconds=protection_policy["process_timeout_seconds"],
|
||||
failure_threshold=protection_policy["failure_threshold"],
|
||||
recovery_seconds=protection_policy["circuit_recovery_seconds"],
|
||||
)
|
||||
self._record_plugin_call_error(
|
||||
plugin=plugin,
|
||||
msg=msg,
|
||||
command_name=command_name,
|
||||
error=timeout_error,
|
||||
)
|
||||
self.LOG.error(
|
||||
self._trace_message(
|
||||
msg,
|
||||
f"插件 {plugin.name} 执行超时,timeout={protection_policy['process_timeout_seconds']}s "
|
||||
f"circuit_state={failure_record.get('circuit_state')} "
|
||||
f"consecutive_failures={failure_record.get('consecutive_failures')}"
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
protection_policy = self._build_message_plugin_protection_policy(plugin)
|
||||
failure_record = self.plugin_manager.record_plugin_execution_failure(
|
||||
plugin,
|
||||
failure_type="error",
|
||||
error_message=str(e),
|
||||
process_time_ms=self._elapsed_ms(started_at),
|
||||
timeout_seconds=0,
|
||||
failure_threshold=protection_policy["failure_threshold"],
|
||||
recovery_seconds=protection_policy["circuit_recovery_seconds"],
|
||||
)
|
||||
self._record_plugin_call_error(
|
||||
plugin=plugin,
|
||||
msg=msg,
|
||||
command_name=command_name,
|
||||
error=e,
|
||||
)
|
||||
self.LOG.error(
|
||||
self._trace_message(
|
||||
msg,
|
||||
f"插件 {plugin.name} 处理消息失败: {e} "
|
||||
f"circuit_state={failure_record.get('circuit_state')} "
|
||||
f"consecutive_failures={failure_record.get('consecutive_failures')}"
|
||||
)
|
||||
)
|
||||
self.LOG.error(self._trace_message(msg, f"插件 {plugin.name} 处理消息失败: {e}"))
|
||||
|
||||
return False
|
||||
|
||||
@@ -796,70 +726,6 @@ class Robot:
|
||||
msg_type = getattr(getattr(msg, "msg_type", None), "name", "")
|
||||
return f"[{msg_type or 'UNKNOWN'}]"
|
||||
|
||||
@staticmethod
|
||||
def _safe_positive_int(value, default: int) -> int:
|
||||
"""把配置中的数字安全转成正整数。"""
|
||||
try:
|
||||
parsed = int(value)
|
||||
return parsed if parsed > 0 else default
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
def _build_message_plugin_protection_policy(self, plugin) -> dict:
|
||||
"""构建消息插件执行保护策略。"""
|
||||
plugin_config = getattr(plugin, "_config", {}) or {}
|
||||
runtime_config = plugin_config.get("runtime", {}) if isinstance(plugin_config, dict) else {}
|
||||
runtime_config = runtime_config if isinstance(runtime_config, dict) else {}
|
||||
breaker_config = runtime_config.get("circuit_breaker", {}) if isinstance(runtime_config, dict) else {}
|
||||
breaker_config = breaker_config if isinstance(breaker_config, dict) else {}
|
||||
|
||||
# 超时策略尽量遵循“显式配置优先,已有内部超时参数兜底”的思路:
|
||||
# 1. 新插件如果有特殊需求,只需要在 runtime / circuit_breaker 下声明自己的超时;
|
||||
# 2. 老插件不改代码也能自动复用现有的 request / llm / render 超时字段;
|
||||
# 3. 最终统一加一个缓冲区,避免外层 wait_for 比插件内部自己的超时还更早打断。
|
||||
explicit_timeout = (
|
||||
runtime_config.get("plugin_process_timeout_seconds")
|
||||
or runtime_config.get("message_timeout_seconds")
|
||||
or breaker_config.get("timeout_seconds")
|
||||
or getattr(plugin, "plugin_process_timeout_seconds", 0)
|
||||
)
|
||||
timeout_candidates = []
|
||||
for attr_name in [
|
||||
"llm_call_timeout_sec",
|
||||
"_request_timeout_seconds",
|
||||
"default_timeout",
|
||||
"_image_render_timeout_seconds",
|
||||
"image_render_timeout_seconds",
|
||||
"_receive_timeout",
|
||||
"_connect_timeout_seconds",
|
||||
"_connect_timeout",
|
||||
]:
|
||||
attr_value = getattr(plugin, attr_name, 0)
|
||||
if isinstance(attr_value, (int, float)) and attr_value > 0:
|
||||
timeout_candidates.append(int(attr_value))
|
||||
|
||||
if explicit_timeout:
|
||||
resolved_timeout = self._safe_positive_int(explicit_timeout, 30)
|
||||
elif timeout_candidates:
|
||||
resolved_timeout = max(timeout_candidates) + 10
|
||||
else:
|
||||
resolved_timeout = 30
|
||||
|
||||
failure_threshold = self._safe_positive_int(
|
||||
breaker_config.get("failure_threshold") or runtime_config.get("circuit_breaker_failure_threshold") or 3,
|
||||
3,
|
||||
)
|
||||
circuit_recovery_seconds = self._safe_positive_int(
|
||||
breaker_config.get("recovery_seconds") or runtime_config.get("circuit_breaker_recovery_seconds") or 180,
|
||||
180,
|
||||
)
|
||||
|
||||
return {
|
||||
"process_timeout_seconds": max(10, min(int(resolved_timeout), 180)),
|
||||
"failure_threshold": max(2, min(int(failure_threshold), 10)),
|
||||
"circuit_recovery_seconds": max(30, min(int(circuit_recovery_seconds), 900)),
|
||||
}
|
||||
|
||||
def _get_stats_collector_plugin(self):
|
||||
"""获取运行中的统计收集插件实例。"""
|
||||
# 统计插件已经从“事件订阅”切到“主链路直接回调”,
|
||||
|
||||
@@ -209,47 +209,6 @@ class PluginScheduleManager:
|
||||
return False
|
||||
return latest_log_at < (expected_at - timedelta(seconds=self._compensation_tolerance_seconds))
|
||||
|
||||
@staticmethod
|
||||
def _build_schedule_health_status(
|
||||
*,
|
||||
enabled: bool,
|
||||
running: bool,
|
||||
last_status: str,
|
||||
latest_success_at,
|
||||
latest_failure_summary: str,
|
||||
) -> str:
|
||||
"""根据调度任务运行态和历史态生成后台健康标签。"""
|
||||
if not enabled:
|
||||
return "disabled"
|
||||
if running:
|
||||
return "running"
|
||||
# 只有“最近一次执行仍是失败”时才把健康态打成 failed,
|
||||
# 避免历史上曾失败过、但后面已经恢复成功的任务一直显示异常。
|
||||
if str(last_status or "").strip().lower() == "failed":
|
||||
return "failed"
|
||||
if latest_success_at or str(last_status or "").strip().lower() == "success":
|
||||
return "healthy"
|
||||
if str(latest_failure_summary or "").strip():
|
||||
return "degraded"
|
||||
return "idle"
|
||||
|
||||
@staticmethod
|
||||
def _build_schedule_health_message(*, health_status: str, latest_success_at, latest_failure_summary: str) -> str:
|
||||
"""生成调度任务列表里展示的简短健康说明。"""
|
||||
if health_status == "disabled":
|
||||
return "任务已停用"
|
||||
if health_status == "running":
|
||||
return "任务正在执行中"
|
||||
if health_status in ("failed", "degraded"):
|
||||
return str(latest_failure_summary or "最近存在失败记录").strip()
|
||||
if health_status == "healthy":
|
||||
if isinstance(latest_success_at, datetime):
|
||||
return f"最近成功于 {latest_success_at.strftime('%Y-%m-%d %H:%M:%S')}"
|
||||
if latest_success_at:
|
||||
return f"最近成功于 {latest_success_at}"
|
||||
return "任务近期执行正常"
|
||||
return "暂无执行记录"
|
||||
|
||||
async def _run_one_schedule(self, schedule_row: Dict[str, Any]) -> Dict[str, Any]:
|
||||
schedule_id = int(schedule_row["id"])
|
||||
action_key = schedule_row.get("action_key")
|
||||
@@ -338,7 +297,6 @@ class PluginScheduleManager:
|
||||
# 日志兜底:进程重启后内存态 last_run_at 会丢失,任务页需要从数据库最新日志恢复显示。
|
||||
schedule_ids = [int(row.get("id")) for row in db_rows if row.get("id") is not None]
|
||||
latest_log_by_schedule = self.db.get_latest_logs_map(schedule_ids)
|
||||
history_summary_by_schedule = self.db.get_schedule_history_summary_map(schedule_ids)
|
||||
|
||||
data = []
|
||||
for row in db_rows:
|
||||
@@ -346,7 +304,6 @@ class PluginScheduleManager:
|
||||
key = f"plugin_schedule:{schedule_id}"
|
||||
runtime = runtime_by_key.get(key, {})
|
||||
latest_log = latest_log_by_schedule.get(schedule_id) or {}
|
||||
history_summary = history_summary_by_schedule.get(schedule_id) or {}
|
||||
merged = dict(row)
|
||||
merged["runtime_job_id"] = runtime.get("id")
|
||||
merged["running"] = runtime.get("running", False)
|
||||
@@ -362,24 +319,6 @@ class PluginScheduleManager:
|
||||
merged["run_count"] = runtime.get("run_count", 0)
|
||||
merged["success_count"] = runtime.get("success_count", 0)
|
||||
merged["fail_count"] = runtime.get("fail_count", 0)
|
||||
merged["latest_success_at"] = history_summary.get("latest_success_at")
|
||||
merged["latest_failed_at"] = history_summary.get("latest_failed_at")
|
||||
merged["latest_failure_summary"] = str(history_summary.get("latest_failure_summary") or "").strip()
|
||||
merged["history_success_count"] = int(history_summary.get("history_success_count", 0) or 0)
|
||||
merged["history_fail_count"] = int(history_summary.get("history_fail_count", 0) or 0)
|
||||
merged["history_total_count"] = int(history_summary.get("history_total_count", 0) or 0)
|
||||
merged["health_status"] = self._build_schedule_health_status(
|
||||
enabled=bool(row.get("enabled", 0)),
|
||||
running=bool(runtime.get("running", False)),
|
||||
last_status=str(merged.get("last_status") or ""),
|
||||
latest_success_at=history_summary.get("latest_success_at"),
|
||||
latest_failure_summary=str(history_summary.get("latest_failure_summary") or ""),
|
||||
)
|
||||
merged["health_message"] = self._build_schedule_health_message(
|
||||
health_status=merged["health_status"],
|
||||
latest_success_at=history_summary.get("latest_success_at"),
|
||||
latest_failure_summary=str(history_summary.get("latest_failure_summary") or ""),
|
||||
)
|
||||
data.append(merged)
|
||||
return data
|
||||
|
||||
|
||||
@@ -883,7 +883,7 @@ class MessageStorage:
|
||||
end_date = current_time.strftime('%Y-%m-%d')
|
||||
|
||||
# 使用新的按日期查询方法
|
||||
messages = self.message_db.get_messages_by_calendar_range(
|
||||
messages = self.message_db.get_messages_by_date_range(
|
||||
group_id,
|
||||
start_date=start_date,
|
||||
end_date=end_date,
|
||||
|
||||
Reference in New Issue
Block a user