feat(health): recent XMPP conversation log panel + stale error fix
User requirement: health tab should show recent XMPP conversations. - bot hooks log_xmpp on inbound (on_msg) and outbound (_deliver_loop) so real chats land in xmpp_messages.jsonl (was: only cron/scanner) - index.html health tab: new '最近对话' panel (last 10 msgs, dir arrow, preview, status, time); refreshHealth updates it incrementally - last_error now shows age and resolved state: once a successful outbound happens after an error, it's shown gray as '已恢复' instead of alarming red forever; unresolved errors still red - health() status no longer degraded by errors that were later resolved by successful outbound
This commit is contained in:
@@ -147,6 +147,14 @@ _inbound_lock = threading.Lock()
|
|||||||
|
|
||||||
RECENT_SENT_MAX = 50
|
RECENT_SENT_MAX = 50
|
||||||
|
|
||||||
|
# ── XMPP 消息日志(dashboard 健康Tab「最近对话」数据源)──
|
||||||
|
try:
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', '..'))
|
||||||
|
from xmpp_logger import log_xmpp as _log_xmpp
|
||||||
|
except Exception:
|
||||||
|
def _log_xmpp(*a, **kw):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def _rs(p):
|
def _rs(p):
|
||||||
"""Parse agent arg from sys.argv, returns agent name string."""
|
"""Parse agent arg from sys.argv, returns agent name string."""
|
||||||
@@ -283,6 +291,7 @@ class XmppAgent(slixmpp.ClientXMPP):
|
|||||||
log.info(f"📩 收到图片(URL body): from={msg['from']} url={body[:80]}")
|
log.info(f"📩 收到图片(URL body): from={msg['from']} url={body[:80]}")
|
||||||
body = f"[IMAGE] {body}"
|
body = f"[IMAGE] {body}"
|
||||||
log.info(f"📩 收到: from={msg['from']} type={msg['type']} body={body[:60]}")
|
log.info(f"📩 收到: from={msg['from']} type={msg['type']} body={body[:60]}")
|
||||||
|
_log_xmpp('in', sender_str := str(msg['from']), f"{AGENT_NAME}@yoin.fun", body)
|
||||||
if ('[executor]' in body and 'gateway_zhiwei' in body):
|
if ('[executor]' in body and 'gateway_zhiwei' in body):
|
||||||
log.info(f"过滤 executor 消息: {body[:80]}...")
|
log.info(f"过滤 executor 消息: {body[:80]}...")
|
||||||
return
|
return
|
||||||
@@ -322,8 +331,10 @@ def _deliver_loop(bot):
|
|||||||
bot.send_message(mto=to, mbody=body, mtype=mtype)
|
bot.send_message(mto=to, mbody=body, mtype=mtype)
|
||||||
asyncio.run(_send(target, text, msg_type))
|
asyncio.run(_send(target, text, msg_type))
|
||||||
bot.mark_sent(text)
|
bot.mark_sent(text)
|
||||||
|
_log_xmpp('out', f"{AGENT_NAME}@yoin.fun", target, text)
|
||||||
log.info(f" 已发送到 {target}: {text[:80]}")
|
log.info(f" 已发送到 {target}: {text[:80]}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
_log_xmpp('out', f"{AGENT_NAME}@yoin.fun", target, text, status='error', error=str(e)[:150])
|
||||||
log.error(f" 发送到 {target} 失败: {e}")
|
log.error(f" 发送到 {target} 失败: {e}")
|
||||||
time.sleep(0.3)
|
time.sleep(0.3)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import urllib.request, json
|
||||||
|
d = json.loads(urllib.request.urlopen('http://127.0.0.1:8899/api/xmpp/health', timeout=120).read())
|
||||||
|
ba = d.get('bot_activity', {})
|
||||||
|
print('status:', d.get('status'))
|
||||||
|
print('last_error:', (ba.get('last_error') or '')[:80])
|
||||||
|
print('last_error_age_sec:', ba.get('last_error_age_sec'))
|
||||||
|
print('last_error_resolved:', ba.get('last_error_resolved'))
|
||||||
|
print('last_outbound_age_sec:', ba.get('last_outbound_age_sec'))
|
||||||
|
print('---')
|
||||||
|
m = json.loads(urllib.request.urlopen('http://127.0.0.1:8899/api/xmpp/messages', timeout=30).read())
|
||||||
|
msgs = m.get('messages', [])
|
||||||
|
print('messages count:', len(msgs))
|
||||||
|
for x in msgs[:5]:
|
||||||
|
print(' ', x.get('timestamp'), x.get('direction'), (x.get('body_preview') or '')[:50])
|
||||||
+62
-4
@@ -1462,6 +1462,9 @@ async function renderHealth() {
|
|||||||
// XMPP 通道健康(独立 fetch,不阻塞主流程)
|
// XMPP 通道健康(独立 fetch,不阻塞主流程)
|
||||||
let xmpp = null;
|
let xmpp = null;
|
||||||
try { xmpp = await fetchJSON('/api/xmpp/health'); } catch(e) {}
|
try { xmpp = await fetchJSON('/api/xmpp/health'); } catch(e) {}
|
||||||
|
// 最近 XMPP 对话日志(bot 挂钩 log_xmpp 后的真实聊天记录)
|
||||||
|
let xmppMsgs = [];
|
||||||
|
try { const md = await fetchJSON('/api/xmpp/messages'); xmppMsgs = md.messages || []; } catch(e) {}
|
||||||
|
|
||||||
const svcs = services.services || [];
|
const svcs = services.services || [];
|
||||||
const summary = services.summary || { ok: 0, total: 0 };
|
const summary = services.summary || { ok: 0, total: 0 };
|
||||||
@@ -1534,7 +1537,12 @@ async function renderHealth() {
|
|||||||
html += '<div><span class="text-slate-500">⚠️ 错误</span><br><span class="font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500') + '" id="hBot_err">' + (ba.errors || 0) + '</span></div>';
|
html += '<div><span class="text-slate-500">⚠️ 错误</span><br><span class="font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500') + '" id="hBot_err">' + (ba.errors || 0) + '</span></div>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
if (ba.last_error) {
|
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>';
|
const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : '';
|
||||||
|
if (ba.last_error_resolved) {
|
||||||
|
html += '<div class="text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded" id="hBot_lastErr">⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error + '</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded" id="hBot_lastErr">⚠️ ' + errAge + ': ' + ba.last_error + '</div>';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hermes Gateway + LLM Provider
|
// Hermes Gateway + LLM Provider
|
||||||
@@ -1549,6 +1557,27 @@ async function renderHealth() {
|
|||||||
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>';
|
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>';
|
if (llm.error) html += '<br><span class="text-[#f85149]" id="hLlmErr">' + llm.error + '</span>';
|
||||||
html += '</div></div>';
|
html += '</div></div>';
|
||||||
|
|
||||||
|
// ── 最近 XMPP 对话日志 ──
|
||||||
|
html += '<div class="mt-3 pt-3 border-t border-slate-800/50">';
|
||||||
|
html += '<div class="text-xs text-slate-500 mb-2">💬 最近对话(' + xmppMsgs.length + ' 条)</div>';
|
||||||
|
if (xmppMsgs.length === 0) {
|
||||||
|
html += '<div class="text-xs text-slate-600 p-2">暂无记录 — bot 重启后对话开始采集</div>';
|
||||||
|
} else {
|
||||||
|
html += '<div class="space-y-1 max-h-64 overflow-y-auto" id="hXmppMsgs">';
|
||||||
|
xmppMsgs.slice(0, 10).forEach(m => {
|
||||||
|
const isIn = m.direction === 'in';
|
||||||
|
const arrow = isIn ? '📩' : '📤';
|
||||||
|
const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]';
|
||||||
|
const statusMark = m.status === 'ok' ? '' : ' <span class="text-[#f85149]">[' + m.status + ']</span>';
|
||||||
|
const who = isIn ? (m.from || '').split('@')[0] : '知微';
|
||||||
|
const t = (m.timestamp || '').slice(11, 16);
|
||||||
|
const preview = (m.body_preview || '').replace(/</g, '<').slice(0, 80);
|
||||||
|
html += '<div class="text-xs p-1.5 rounded bg-slate-800/40 flex gap-2"><span class="' + dirColor + ' shrink-0">' + arrow + ' ' + who + '</span><span class="text-slate-400 flex-1 break-all">' + preview + statusMark + '</span><span class="text-slate-600 shrink-0 font-mono">' + t + '</span></div>';
|
||||||
|
});
|
||||||
|
html += '</div>';
|
||||||
|
}
|
||||||
|
html += '</div>';
|
||||||
html += '</div>';
|
html += '</div>';
|
||||||
}
|
}
|
||||||
el.innerHTML = html;
|
el.innerHTML = html;
|
||||||
@@ -1563,9 +1592,10 @@ async function refreshHealth() {
|
|||||||
const el = document.getElementById('tab-health');
|
const el = document.getElementById('tab-health');
|
||||||
if (!el || el.classList.contains('hidden') || !el.dataset.rendered) return;
|
if (!el || el.classList.contains('hidden') || !el.dataset.rendered) return;
|
||||||
try {
|
try {
|
||||||
const [services, xmpp] = await Promise.all([
|
const [services, xmpp, msgData] = await Promise.all([
|
||||||
fetchJSON('/api/services'),
|
fetchJSON('/api/services'),
|
||||||
fetchJSON('/api/xmpp/health')
|
fetchJSON('/api/xmpp/health'),
|
||||||
|
fetchJSON('/api/xmpp/messages').catch(() => null)
|
||||||
]);
|
]);
|
||||||
const svcs = (services.services || []);
|
const svcs = (services.services || []);
|
||||||
const sum = services.summary || {};
|
const sum = services.summary || {};
|
||||||
@@ -1613,12 +1643,40 @@ async function refreshHealth() {
|
|||||||
if (bi) bi.textContent = ba.inbound || 0;
|
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 (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 (be) { be.textContent = ba.errors || 0; be.className = 'font-mono ' + (ba.errors ? 'text-[#f85149]' : 'text-slate-500'); }
|
||||||
if (bl) bl.textContent = ba.last_error || '';
|
if (bl) {
|
||||||
|
const errAge = ba.last_error_age_sec >= 0 ? Math.round(ba.last_error_age_sec/60) + '分钟前' : '';
|
||||||
|
if (ba.last_error && ba.last_error_resolved) {
|
||||||
|
bl.textContent = '⚠️ 历史错误(' + errAge + ',已恢复): ' + ba.last_error;
|
||||||
|
bl.className = 'text-xs text-slate-500 mt-2 p-2 bg-slate-800/30 rounded';
|
||||||
|
} else if (ba.last_error) {
|
||||||
|
bl.textContent = '⚠️ ' + errAge + ': ' + ba.last_error;
|
||||||
|
bl.className = 'text-xs text-[#f85149] mt-2 p-2 bg-red-900/20 rounded';
|
||||||
|
} else {
|
||||||
|
bl.textContent = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
// LLM Provider
|
// LLM Provider
|
||||||
const llm = xmpp.llm_provider || {};
|
const llm = xmpp.llm_provider || {};
|
||||||
const ls = document.getElementById('hLlmStatus'), le = document.getElementById('hLlmErr');
|
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 (ls) { ls.textContent = llm.status || '?'; ls.className = 'font-mono ' + (llm.status === 'ok' ? 'text-[#3fb950]' : 'text-[#f85149]'); }
|
||||||
if (le) le.textContent = llm.error || '';
|
if (le) le.textContent = llm.error || '';
|
||||||
|
|
||||||
|
// 最近对话列表
|
||||||
|
const msgBox = document.getElementById('hXmppMsgs');
|
||||||
|
if (msgBox && msgData && msgData.messages) {
|
||||||
|
let mh = '';
|
||||||
|
msgData.messages.slice(0, 10).forEach(m => {
|
||||||
|
const isIn = m.direction === 'in';
|
||||||
|
const arrow = isIn ? '📩' : '📤';
|
||||||
|
const dirColor = isIn ? 'text-[#58a6ff]' : 'text-[#3fb950]';
|
||||||
|
const statusMark = m.status === 'ok' ? '' : ' <span class="text-[#f85149]">[' + m.status + ']</span>';
|
||||||
|
const who = isIn ? (m.from || '').split('@')[0] : '知微';
|
||||||
|
const t = (m.timestamp || '').slice(11, 16);
|
||||||
|
const preview = (m.body_preview || '').replace(/</g, '<').slice(0, 80);
|
||||||
|
mh += '<div class="text-xs p-1.5 rounded bg-slate-800/40 flex gap-2"><span class="' + dirColor + ' shrink-0">' + arrow + ' ' + who + '</span><span class="text-slate-400 flex-1 break-all">' + preview + statusMark + '</span><span class="text-slate-600 shrink-0 font-mono">' + t + '</span></div>';
|
||||||
|
});
|
||||||
|
msgBox.innerHTML = mh;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新时间戳
|
// 更新时间戳
|
||||||
|
|||||||
+8
-2
@@ -306,6 +306,7 @@ def health():
|
|||||||
|
|
||||||
last_inbound_ts = max((_line_epoch(l) for l in inbound), default=0)
|
last_inbound_ts = max((_line_epoch(l) for l in inbound), default=0)
|
||||||
last_outbound_ts = max((_line_epoch(l) for l in outbound), default=0)
|
last_outbound_ts = max((_line_epoch(l) for l in outbound), default=0)
|
||||||
|
last_error_ts = max((_line_epoch(l) for l in errors), default=0)
|
||||||
result["bot_activity"] = {
|
result["bot_activity"] = {
|
||||||
"inbound": len(inbound), "outbound": len(outbound), "errors": len(errors),
|
"inbound": len(inbound), "outbound": len(outbound), "errors": len(errors),
|
||||||
"last_error": errors[-1][:250] if errors else None,
|
"last_error": errors[-1][:250] if errors else None,
|
||||||
@@ -313,9 +314,14 @@ def health():
|
|||||||
"last_outbound": outbound[-1][:200] if outbound else None,
|
"last_outbound": outbound[-1][:200] if outbound else None,
|
||||||
"last_inbound_age_sec": int(now_epoch - last_inbound_ts) if last_inbound_ts else -1,
|
"last_inbound_age_sec": int(now_epoch - last_inbound_ts) if last_inbound_ts else -1,
|
||||||
"last_outbound_age_sec": int(now_epoch - last_outbound_ts) if last_outbound_ts else -1,
|
"last_outbound_age_sec": int(now_epoch - last_outbound_ts) if last_outbound_ts else -1,
|
||||||
|
"last_error_age_sec": int(now_epoch - last_error_ts) if last_error_ts else -1,
|
||||||
|
# 错误之后已有成功出站 → 该错误已被覆盖,不算当前问题
|
||||||
|
"last_error_resolved": bool(last_error_ts and last_outbound_ts > last_error_ts),
|
||||||
}
|
}
|
||||||
if errors and not outbound: result["status"] = "degraded"
|
# 只有未覆盖的错误才降级状态
|
||||||
if inbound and not outbound: result["status"] = "degraded"
|
unresolved_error = last_error_ts and not (last_outbound_ts > last_error_ts)
|
||||||
|
if errors and not outbound and unresolved_error: result["status"] = "degraded"
|
||||||
|
if inbound and not outbound and unresolved_error: result["status"] = "degraded"
|
||||||
except Exception:
|
except Exception:
|
||||||
result["bot_activity"] = {"error": "journalctl failed"}
|
result["bot_activity"] = {"error": "journalctl failed"}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user