feat: Hermes Gateway + LLM provider monitoring, root cause visible in health tab

This commit is contained in:
hmo
2026-07-19 13:25:37 +08:00
parent 50ae4e1cc9
commit 0802046d5c
2 changed files with 81 additions and 53 deletions
+24 -9
View File
@@ -1526,7 +1526,7 @@ async function renderHealth() {
html += '<div><span class="text-slate-500">状态</span><br><span class="font-mono" id="hXmppDetail_st">' + xmpp.status + '</span></div>';
html += '</div>';
// Bot 活动(知微)
// Bot 活动
const ba = xmpp.bot_activity || {};
html += '<div class="grid grid-cols-3 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
html += '<div><span class="text-slate-500">📩 入站</span><br><span class="font-mono text-[#58a6ff]" id="hBot_in">' + (ba.inbound || 0) + '</span></div>';
@@ -1536,6 +1536,19 @@ async function renderHealth() {
if (ba.last_error) {
html += '<div class="text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded" id="hBot_lastErr">' + ba.last_error + '</div>';
}
// Hermes Gateway + LLM Provider
const gw = xmpp.gateways || {};
const llm = xmpp.llm_provider || {};
html += '<div class="grid grid-cols-2 gap-2 text-xs mt-3 pt-3 border-t border-slate-800/50">';
html += '<div><span class="text-slate-500">🔌 Hermes Gateway</span><br>';
Object.entries(gw).forEach(([k,v]) => {
html += '<span class="font-mono ' + (v.alive ? 'text-[#3fb950]' : 'text-[#f85149]') + '">' + k + ':' + v.port + (v.alive ? ' ✅' : ' ❌') + '</span> ';
});
html += '</div>';
html += '<div><span class="text-slate-500">🧠 LLM Provider</span><br><span class="font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]') + '" id="hLlmStatus">' + (llm.status || '?') + '</span>';
if (llm.error) html += '<br><span class="text-[#f85149]" id="hLlmErr">' + llm.error + '</span>';
html += '</div></div>';
html += '</div>';
}
el.innerHTML = html;
@@ -1578,7 +1591,7 @@ async function refreshHealth() {
}
});
// 更新 XMPP 详情
// 更新 XMPP 详情 + Bot 活动
if (xmpp) {
const d_age = document.getElementById('hXmppDetail_age');
const d_err = document.getElementById('hXmppDetail_err');
@@ -1588,22 +1601,24 @@ async function refreshHealth() {
if (d_err) d_err.textContent = xmpp.error_rate_1h + '%';
if (d_eja) d_eja.textContent = xmpp.ejabberd || '?';
if (d_st) d_st.textContent = xmpp.status;
// 更新卡片颜色
if (ageEl && xmpp) ageEl.style.color = xmpp.last_message_age_sec > 600 ? '#f85149' : '#3fb950';
if (labelEl && xmpp) {
const labels = {ok:'XMPP 正常', degraded:'XMPP 降级', critical:'XMPP 断联'};
labelEl.textContent = labels[xmpp.status] || 'XMPP 无数据';
}
// 更新 Bot 活动
// Bot 活动
const ba = xmpp.bot_activity || {};
const bi = document.getElementById('hBot_in');
const bo = document.getElementById('hBot_out');
const be = document.getElementById('hBot_err');
const bl = document.getElementById('hBot_lastErr');
const bi = document.getElementById('hBot_in'), bo = document.getElementById('hBot_out');
const be = document.getElementById('hBot_err'), bl = document.getElementById('hBot_lastErr');
if (bi) bi.textContent = ba.inbound || 0;
if (bo) { bo.textContent = ba.outbound || 0; bo.className = 'font-mono ' + (ba.outbound ? 'text-[#3fb950]' : (ba.inbound ? 'text-[#f85149]' : 'text-slate-500')); }
if (be) { be.textContent = ba.errors || 0; be.className = 'font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500'); }
if (bl && ba.last_error) bl.textContent = ba.last_error;
if (bl) bl.textContent = ba.last_error || '';
// LLM Provider
const llm = xmpp.llm_provider || {};
const ls = document.getElementById('hLlmStatus'), le = document.getElementById('hLlmErr');
if (ls) { ls.textContent = llm.status || '?'; ls.className = 'font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]'); }
if (le) le.textContent = llm.error || '';
}
// 更新时间戳
+57 -44
View File
@@ -142,78 +142,91 @@ def stats():
def health():
"""快速健康检查:返回最后消息年龄 + 最近1h错误率 + bot 日志状态"""
result = {"status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0, "bot_activity": {}}
"""快速健康检查:返回最后消息年龄 + 最近1h错误率 + bot 日志状态 + Hermes Gateway 状态"""
result = {"status": "no_data", "last_message_age_sec": -1, "error_rate_1h": 0,
"bot_activity": {}, "gateways": {}, "llm_provider": {}}
# 1. 检查 xmpp_messages.jsonl
now_epoch = _time.time()
if LOG_FILE.exists():
now_epoch = _time.time()
last_epoch = 0
recent_total = 0
recent_errors = 0
one_hour_ago = now_epoch - 3600
try:
with open(LOG_FILE, "r", encoding="utf-8") as f:
for line in f:
try:
e = json.loads(line)
ep = e.get("epoch", 0)
if ep > last_epoch:
last_epoch = ep
if ep > last_epoch: last_epoch = ep
if ep > one_hour_ago:
recent_total += 1
if e["status"] != "ok":
recent_errors += 1
except Exception:
continue
except Exception:
pass
if e["status"] != "ok": recent_errors += 1
except Exception: continue
except Exception: pass
result["last_message_age_sec"] = int(now_epoch - last_epoch) if last_epoch else -1
result["error_rate_1h"] = round(recent_errors / recent_total * 100, 1) if recent_total else 0
last_age = int(now_epoch - last_epoch) if last_epoch else -1
err_rate = round(recent_errors / recent_total * 100, 1) if recent_total else 0
result["last_message_age_sec"] = last_age
result["error_rate_1h"] = err_rate
# 2. 检查知微 Bot systemd journal(最近5分钟)
# 2. 检查知微 Bot systemd journal + 持久化状态
try:
import subprocess
r = subprocess.run(
["journalctl", "-u", "xmpp-zhiwei", "--no-pager", "--since", "5 min ago", "-o", "cat"],
capture_output=True, timeout=5, text=True
)
r = subprocess.run(["journalctl", "-u", "xmpp-zhiwei", "--no-pager", "--since", "30 min ago", "-o", "cat"],
capture_output=True, timeout=5, text=True)
lines = [l for l in r.stdout.split("\n") if l.strip()]
inbound = [l for l in lines if "📩 收到" in l]
outbound = [l for l in lines if "📤 发送" in l or "📤" in l]
errors = [l for l in lines if "ERROR" in l or "error" in l or "timeout" in l or "Timeout" in l]
errors = [l for l in lines if "ERROR" in l or "TimeoutError" in l or "timed out" in l]
result["bot_activity"] = {
"inbound": len(inbound),
"outbound": len(outbound),
"errors": len(errors),
"last_error": errors[-1][:200] if errors else None,
"last_inbound": inbound[-1][:200] if inbound else None,
"inbound": len(inbound), "outbound": len(outbound), "errors": len(errors),
"last_error": errors[-1][:250] if errors else None,
"last_inbound": inbound[-1][:250] if inbound else None,
"last_outbound": outbound[-1][:200] if outbound else None,
}
# 有错误且无出站 → degraded
if errors and not outbound:
if result.get("status") != "critical":
result["status"] = "degraded"
# 有入站无出站 → 可能卡住了
if inbound and not outbound:
result["status"] = "degraded"
if errors and not outbound: result["status"] = "degraded"
if inbound and not outbound: result["status"] = "degraded"
except Exception:
result["bot_activity"] = {"error": "journalctl failed"}
# 3. 综合判定
# 3. 检查 Hermes Gateway 各 profile 状态(端口检测)
try:
import socket
profiles = {"zhiwei": 8643, "mohe": 8642, "xiaoguo": 8645}
for name, port in profiles.items():
ok = False
try:
s = socket.create_connection(("127.0.0.1", port), timeout=2)
s.close()
ok = True
except Exception: pass
result["gateways"][name] = {"port": port, "alive": ok}
# 测试知微的 LLM 调用是否可达
if result["gateways"].get("zhiwei", {}).get("alive"):
try:
import urllib.request
payload = json.dumps({"model": "gpt-4", "messages": [{"role": "user", "content": "ping"}],
"max_tokens": 5, "stream": False}).encode()
req = urllib.request.Request("http://127.0.0.1:8643/v1/chat/completions",
data=payload, headers={"Content-Type": "application/json",
"Authorization": "Bearer hermes123"})
urllib.request.urlopen(req, timeout=8)
result["llm_provider"] = {"status": "ok", "latency": "fast"}
except Exception as e:
result["llm_provider"] = {"status": "timeout" if "timeout" in str(e).lower() else "error",
"error": str(e)[:150]}
except Exception as e:
result["gateways"] = {"error": str(e)[:100]}
# 4. 综合判定
if result.get("status") != "degraded":
la = result.get("last_message_age_sec", -1)
er = result.get("error_rate_1h", 0)
if la < 0:
result["status"] = "no_data"
elif la > 600:
result["status"] = "critical"
elif er > 50:
result["status"] = "degraded"
elif la > 0:
result["status"] = "ok"
llm = result.get("llm_provider", {}).get("status", "")
if llm in ("timeout", "error"): result["status"] = "degraded"
elif la < 0: result["status"] = "no_data"
elif la > 600: result["status"] = "critical"
elif er > 50: result["status"] = "degraded"
elif la > 0: result["status"] = "ok"
return result