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:
hmo
2026-07-20 00:07:28 +08:00
parent cb334ddd54
commit 058c42ce27
4 changed files with 95 additions and 6 deletions
+62 -4
View File
@@ -1462,6 +1462,9 @@ async function renderHealth() {
// XMPP 通道健康(独立 fetch,不阻塞主流程)
let xmpp = null;
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 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>';
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
@@ -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>';
if (llm.error) html += '<br><span class="text-[#f85149]" id="hLlmErr">' + llm.error + '</span>';
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, '&lt;').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>';
}
el.innerHTML = html;
@@ -1563,9 +1592,10 @@ async function refreshHealth() {
const el = document.getElementById('tab-health');
if (!el || el.classList.contains('hidden') || !el.dataset.rendered) return;
try {
const [services, xmpp] = await Promise.all([
const [services, xmpp, msgData] = await Promise.all([
fetchJSON('/api/services'),
fetchJSON('/api/xmpp/health')
fetchJSON('/api/xmpp/health'),
fetchJSON('/api/xmpp/messages').catch(() => null)
]);
const svcs = (services.services || []);
const sum = services.summary || {};
@@ -1613,12 +1643,40 @@ async function refreshHealth() {
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) 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
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 || '';
// 最近对话列表
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, '&lt;').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;
}
}
// 更新时间戳