新增转图运行时健康监控与手动预热
变更项:\n1. 在 markdown_to_image 增加 get_md2img_health_snapshot 健康快照能力,输出 runtime 线程、事件循环、浏览器连接、启动来源与 PID 状态。\n2. 新增系统接口 GET /api/system/md2img_health,支持后台查询转图运行时健康信息。\n3. 新增系统接口 POST /api/system/md2img_warmup,支持后台手动触发转图预热并返回最新状态。\n4. 在资源监控页面接入转图健康状态条,展示运行时在线状态、浏览器连接状态及关键摘要信息。\n5. 在资源监控页面增加转图预热与状态刷新按钮,便于线上快速自愈与排障。\n6. 补充中文注释与错误提示,保持后端与前端可观测性一致。
This commit is contained in:
@@ -11,6 +11,7 @@ from collections import deque
|
||||
import gzip
|
||||
import json
|
||||
import yaml
|
||||
from utils.markdown_to_image import get_md2img_health_snapshot, warmup_md2img_browser_sync
|
||||
|
||||
# 创建系统信息蓝图
|
||||
system_bp = Blueprint('system', __name__)
|
||||
@@ -305,6 +306,47 @@ def update_system_llm_config():
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@system_bp.route('/api/system/md2img_health', methods=['GET'])
|
||||
@login_required
|
||||
def get_md2img_health():
|
||||
"""查询 Markdown 转图运行时健康状态。"""
|
||||
try:
|
||||
# 默认只读取状态,不主动拉起 runtime。
|
||||
# 当后台希望“刷新并顺便拉起”时,可传 ensure_runtime=true。
|
||||
ensure_runtime = str(request.args.get('ensure_runtime', 'false')).strip().lower() in {'1', 'true', 'yes', 'on'}
|
||||
data = get_md2img_health_snapshot(ensure_runtime=ensure_runtime)
|
||||
return jsonify({"success": True, "data": data})
|
||||
except Exception as e:
|
||||
logger.error(f"获取 md2img 健康状态失败: {e}")
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@system_bp.route('/api/system/md2img_warmup', methods=['POST'])
|
||||
@login_required
|
||||
def trigger_md2img_warmup():
|
||||
"""手动触发 Markdown 转图浏览器预热。"""
|
||||
try:
|
||||
payload = request.get_json(silent=True) or {}
|
||||
timeout_seconds = int(payload.get('timeout_seconds', 45) or 45)
|
||||
timeout_seconds = max(10, min(timeout_seconds, 180))
|
||||
ok = warmup_md2img_browser_sync(timeout_seconds=timeout_seconds)
|
||||
data = get_md2img_health_snapshot(ensure_runtime=False)
|
||||
if ok:
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"message": f"预热完成(timeout={timeout_seconds}s)",
|
||||
"data": data,
|
||||
})
|
||||
return jsonify({
|
||||
"success": False,
|
||||
"message": f"预热失败(timeout={timeout_seconds}s),请查看运行日志",
|
||||
"data": data,
|
||||
}), 500
|
||||
except Exception as e:
|
||||
logger.error(f"触发 md2img 预热失败: {e}")
|
||||
return jsonify({"success": False, "message": str(e)}), 500
|
||||
|
||||
|
||||
@system_bp.route('/api/restart_service', methods=['POST'])
|
||||
@login_required
|
||||
def restart_service():
|
||||
|
||||
@@ -9,8 +9,23 @@
|
||||
<div class="page-eyebrow">System Workspace</div>
|
||||
<h1>资源监控</h1>
|
||||
<p>直接在后台查看系统资源变化与运行状态,保持监控入口简洁清晰。</p>
|
||||
<div class="md2img-health-inline" v-loading="md2imgLoading">
|
||||
<span class="health-title">转图运行时</span>
|
||||
<el-tag size="mini" :type="runtimeTagType">{% raw %}{{ runtimeTagText }}{% endraw %}</el-tag>
|
||||
<el-tag size="mini" :type="browserTagType">{% raw %}{{ browserTagText }}{% endraw %}</el-tag>
|
||||
<span class="health-brief">{% raw %}{{ md2imgBrief }}{% endraw %}</span>
|
||||
<span class="health-time" v-if="md2imgHealth && md2imgHealth.timestamp">
|
||||
{% raw %}{{ md2imgHealth.timestamp }}{% endraw %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-hero-actions">
|
||||
<el-button type="success" plain :loading="md2imgWarming" @click="warmupMd2Img">
|
||||
<i class="el-icon-magic-stick"></i> 预热转图
|
||||
</el-button>
|
||||
<el-button type="info" plain :loading="md2imgLoading" @click="loadMd2ImgHealth">
|
||||
<i class="el-icon-refresh"></i> 刷新转图状态
|
||||
</el-button>
|
||||
<el-button type="primary" plain @click="reloadIframe"><i class="el-icon-refresh"></i> 刷新面板</el-button>
|
||||
<el-button type="primary" @click="openInNewTab"><i class="el-icon-top-right"></i> 新窗口打开</el-button>
|
||||
<el-button type="danger" @click="confirmRestart"><i class="el-icon-refresh-left"></i> 重启服务</el-button>
|
||||
@@ -41,11 +56,42 @@
|
||||
return {
|
||||
currentView: '14',
|
||||
frameUrl: '{{ src_url }}',
|
||||
restarting: false
|
||||
restarting: false,
|
||||
md2imgLoading: false,
|
||||
md2imgWarming: false,
|
||||
md2imgHealth: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
runtimeTagText() {
|
||||
const runtime = this.md2imgHealth && this.md2imgHealth.runtime ? this.md2imgHealth.runtime : {};
|
||||
return runtime.loop_running ? '运行时在线' : '运行时未就绪';
|
||||
},
|
||||
runtimeTagType() {
|
||||
const runtime = this.md2imgHealth && this.md2imgHealth.runtime ? this.md2imgHealth.runtime : {};
|
||||
return runtime.loop_running ? 'success' : 'warning';
|
||||
},
|
||||
browserTagText() {
|
||||
const browser = this.md2imgHealth && this.md2imgHealth.browser ? this.md2imgHealth.browser : {};
|
||||
return browser.connected ? '浏览器已连接' : '浏览器未连接';
|
||||
},
|
||||
browserTagType() {
|
||||
const browser = this.md2imgHealth && this.md2imgHealth.browser ? this.md2imgHealth.browser : {};
|
||||
return browser.connected ? 'success' : 'info';
|
||||
},
|
||||
md2imgBrief() {
|
||||
if (!this.md2imgHealth) return '尚未获取状态';
|
||||
const runtime = this.md2imgHealth.runtime || {};
|
||||
const browser = this.md2imgHealth.browser || {};
|
||||
const loopText = runtime.loop_id ? `loop=${runtime.loop_id}` : 'loop=-';
|
||||
const pidText = browser.pid ? `pid=${browser.pid}` : 'pid=-';
|
||||
const sourceText = browser.launch_source ? `source=${browser.launch_source}` : 'source=-';
|
||||
return `${loopText} · ${pidText} · ${sourceText}`;
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.currentView = '14';
|
||||
this.loadMd2ImgHealth();
|
||||
},
|
||||
methods: {
|
||||
reloadIframe() {
|
||||
@@ -80,6 +126,41 @@
|
||||
} finally {
|
||||
this.restarting = false;
|
||||
}
|
||||
},
|
||||
async loadMd2ImgHealth() {
|
||||
if (this.md2imgLoading) return;
|
||||
this.md2imgLoading = true;
|
||||
try {
|
||||
// 默认不强制拉起 runtime,避免纯查看状态时引入副作用。
|
||||
const response = await axios.get('/api/system/md2img_health');
|
||||
if (response.data && response.data.success) {
|
||||
this.md2imgHealth = response.data.data || null;
|
||||
} else {
|
||||
this.$message.error(response.data?.message || '获取转图状态失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '获取转图状态失败');
|
||||
} finally {
|
||||
this.md2imgLoading = false;
|
||||
}
|
||||
},
|
||||
async warmupMd2Img() {
|
||||
if (this.md2imgWarming) return;
|
||||
this.md2imgWarming = true;
|
||||
try {
|
||||
const response = await axios.post('/api/system/md2img_warmup', { timeout_seconds: 60 });
|
||||
if (response.data && response.data.success) {
|
||||
this.$message.success(response.data.message || '转图预热成功');
|
||||
this.md2imgHealth = response.data.data || this.md2imgHealth;
|
||||
} else {
|
||||
this.$message.error(response.data?.message || '转图预热失败');
|
||||
}
|
||||
} catch (error) {
|
||||
this.$message.error(error.response?.data?.message || '转图预热失败');
|
||||
} finally {
|
||||
this.md2imgWarming = false;
|
||||
this.loadMd2ImgHealth();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -96,6 +177,20 @@
|
||||
.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; }
|
||||
.md2img-health-inline {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
background: rgba(255,255,255,0.72);
|
||||
border: 1px solid rgba(148, 163, 184, 0.18);
|
||||
}
|
||||
.health-title { font-size: 12px; font-weight: 700; color: #334155; }
|
||||
.health-brief { font-size: 12px; color: #475569; }
|
||||
.health-time { font-size: 12px; color: #94a3b8; }
|
||||
.workspace-header { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.workspace-header h3 { font-size: 18px; margin-bottom: 4px; }
|
||||
.workspace-header p { font-size: 13px; color: #64748b; }
|
||||
@@ -112,6 +207,7 @@
|
||||
.workspace-header { flex-direction: column; align-items: flex-start; }
|
||||
.page-hero-actions { flex-wrap: wrap; }
|
||||
.iframe-url { max-width: 100%; }
|
||||
.md2img-health-inline { width: 100%; }
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
@@ -686,6 +686,73 @@ def _get_md2img_runtime() -> _Md2ImgRuntime:
|
||||
return _MD2IMG_RUNTIME
|
||||
|
||||
|
||||
def get_md2img_health_snapshot(ensure_runtime: bool = False) -> dict:
|
||||
"""获取 Markdown 转图运行时健康快照(同步)。
|
||||
|
||||
Args:
|
||||
ensure_runtime: 是否在采集前确保运行时已启动。
|
||||
- False: 仅观察当前状态,不主动拉起线程;
|
||||
- True: 先启动 md2img runtime,再返回状态,适合后台手动“刷新并拉起”场景。
|
||||
|
||||
Returns:
|
||||
dict: 结构化健康信息,便于后台页面直接展示。
|
||||
"""
|
||||
runtime = _get_md2img_runtime()
|
||||
if ensure_runtime:
|
||||
# 显式拉起运行时,方便后台做一次“冷启动检查”。
|
||||
runtime.ensure_started()
|
||||
|
||||
thread_obj = getattr(runtime, "_thread", None)
|
||||
loop_obj = getattr(runtime, "_loop", None)
|
||||
|
||||
runtime_started = bool(thread_obj is not None)
|
||||
runtime_thread_alive = bool(thread_obj.is_alive()) if thread_obj else False
|
||||
runtime_loop_running = bool(loop_obj.is_running()) if loop_obj else False
|
||||
runtime_loop_id = id(loop_obj) if loop_obj else None
|
||||
runtime_thread_name = thread_obj.name if thread_obj else ""
|
||||
|
||||
browser_manager = _BROWSER_MANAGER
|
||||
browser_connected = False
|
||||
browser_loop_owner = None
|
||||
browser_launch_source = ""
|
||||
browser_pid = None
|
||||
browser_proc_alive = None
|
||||
browser_error = ""
|
||||
if browser_manager is not None:
|
||||
try:
|
||||
browser_obj = getattr(browser_manager, "_browser", None)
|
||||
browser_connected = bool(browser_obj and browser_obj.is_connected())
|
||||
browser_loop_owner = getattr(browser_manager, "_owner_loop_id", None)
|
||||
browser_launch_source = str(getattr(browser_manager, "_last_launch_source", "") or "")
|
||||
browser_pid = getattr(getattr(browser_obj, "process", None), "pid", None) if browser_obj else None
|
||||
if browser_pid:
|
||||
# 通过 psutil 二次确认进程是否仍在,避免只看到历史 PID。
|
||||
browser_proc_alive = psutil.pid_exists(int(browser_pid))
|
||||
else:
|
||||
browser_proc_alive = None
|
||||
except Exception as e:
|
||||
browser_error = str(e)
|
||||
|
||||
return {
|
||||
"timestamp": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()),
|
||||
"runtime": {
|
||||
"started": runtime_started,
|
||||
"thread_alive": runtime_thread_alive,
|
||||
"thread_name": runtime_thread_name,
|
||||
"loop_running": runtime_loop_running,
|
||||
"loop_id": runtime_loop_id,
|
||||
},
|
||||
"browser": {
|
||||
"connected": browser_connected,
|
||||
"owner_loop_id": browser_loop_owner,
|
||||
"launch_source": browser_launch_source,
|
||||
"pid": browser_pid,
|
||||
"pid_alive": browser_proc_alive,
|
||||
"error": browser_error,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _run_in_md2img_runtime(coro, timeout_seconds: Optional[int] = None):
|
||||
"""在 md2img 专用事件循环中执行协程,并在当前调用方 loop 中异步等待结果。"""
|
||||
runtime = _get_md2img_runtime()
|
||||
|
||||
Reference in New Issue
Block a user