码桶
发现社区成员的开源项目
failure-report.js21.8 KB
'use strict';
// 任务清单第4项:结构化、可直接粘贴到论坛的故障报告生成器。
//
// 设计原则(跟第1节交接文档里说的一致):这是纯粹的格式化/组装工作,不新增任何
// 检测逻辑——所有原始数据(当前状态、最近事件、AI诊断结论、知识库匹配)
// 早就存在于 store.js / checker.js 写入的事件里,这里只是把它们捞出来拼成一段人话。
//
// 特意不做"精确定位单次故障起止时间"这种复杂窗口计算(TCP层的down/recovered和
// 二层可用性检测的down/ok是两条独立的状态线,各自的"发作期"边界在store.js里
// 也没有存专门的开始时间字段,硬要精确切割容易做出脆弱的猜测逻辑)。改成更朴素、
// 更不容易出错的做法:当前状态快照 + 最近一段时间线(按时间顺序摘录有意义的事件)+
// 已触发过的自愈动作汇总 + AI诊断结论(如果有)+ 知识库建议(如果时间线里的事件带了kb字段)。
// 这份报告的目的是"帮用户在论坛描述清楚发生了什么",不是精确的事故报告,朴素但可靠更重要。
const store = require('./store');
const SUPPORTED_LANGS = ['zh', 'en', 'ja', 'de', 'ru'];
function normalizeLang(lang) {
return SUPPORTED_LANGS.includes(lang) ? lang : 'zh';
}
// 事件时间线只挑"对故障排查有参考价值"的类型,纯噪音类型(比如每轮都可能出现的
// usability_ok/recovered 之外的重复down)已经被 checker.js 自己的 dedup 挡掉了,
// 这里不用再额外过滤——store.getEvents() 拿到的本来就是已经去重过的事件。
const RELEVANT_EVENT_TYPES = new Set([
'down', 'recovered', 'restart', 'restart_failed',
'usability_down', 'usability_ok', 'usability_check_error',
'usability_restart_suggested', 'usability_auto_restart',
'pool_activated', 'pool_deactivated',
'ai_diagnosis', 'ai_diagnosis_failed'
]);
const I18N = {
zh: {
title: 'NodeNanny 故障报告',
generatedAt: '生成时间',
nodeName: '节点名称',
tcpStatus: 'TCP存活状态',
usabilityStatus: '二层可用性状态',
since: '自',
statusOk: '正常',
statusDown: '异常(down)',
statusUnknown: '未知',
usabilityOk: '正常',
usabilityDown: '异常',
usabilityCheckError: '检测手段本身出错',
usabilityDisabled: '未启用/无数据',
activeSource: '当前订阅来源',
activeSourceSelf: '自建节点',
activeSourcePool: '应急流量池',
timeline: '最近事件时间线(从新到旧)',
noTimeline: '(最近没有值得记录的事件)',
selfHealActions: '本次已尝试过的自愈动作',
noSelfHealActions: '(还没有触发过任何自动重启/切换动作)',
restartAttemptedToday: (n) => `今日累计尝试重启 ${n} 次`,
restartSucceededToday: (n) => `其中重启命令实际执行成功 ${n} 次`,
aiDiagnosis: 'AI诊断结论(最近一次)',
noAiDiagnosis: '(未启用AI诊断,或还没有触发过)',
aiDiagnosisAt: '诊断时间',
aiDiagnosisFailed: 'AI诊断本身调用失败',
kbSuggestions: '知识库匹配到的相关建议',
noKbSuggestions: '(最近事件没有匹配到知识库词条)',
footer: '(本报告由 NodeNanny 自动生成,可直接粘贴到论坛帖子里寻求帮助)',
events: {
down: () => '节点TCP端口检测失败(判定为down)',
recovered: (p) => p.downMinutes != null ? `节点已恢复正常(本次异常持续约 ${p.downMinutes} 分钟)` : '节点已恢复正常',
restart: (p) => `尝试自动重启服务 ${p.serviceName || ''}`.trim(),
restart_failed: (p) => `自动重启命令执行失败${p.error ? ':' + p.error : ''}`,
usability_down: (p) => `二层可用性检测判定为异常${p.detail ? '(' + p.detail + ')' : ''}`,
usability_ok: () => '二层可用性检测恢复正常',
usability_check_error: (p) => `二层可用性检测手段本身出错${p.detail ? '(' + p.detail + ')' : ''}`,
usability_restart_suggested: () => '连续多次判定异常,已发送"建议手动重启"通知(未自动执行)',
usability_auto_restart: (p) => `连续多次判定异常,已自动触发一次重启(${p.restartOk ? '执行成功' : '执行失败'})`,
pool_activated: (p) => `已切换到应急流量池(当前池内 ${p.count || 0} 个可用节点)`,
pool_deactivated: () => '已从应急流量池切回自建节点',
ai_diagnosis: () => '已触发一次AI诊断',
ai_diagnosis_failed: (p) => `AI诊断调用失败${p.error ? ':' + p.error : ''}`
}
},
en: {
title: 'NodeNanny Failure Report',
generatedAt: 'Generated at',
nodeName: 'Node name',
tcpStatus: 'TCP alive status',
usabilityStatus: 'Layer-2 usability status',
since: 'since',
statusOk: 'OK',
statusDown: 'Down',
statusUnknown: 'Unknown',
usabilityOk: 'OK',
usabilityDown: 'Down',
usabilityCheckError: 'Check mechanism itself errored',
usabilityDisabled: 'Disabled / no data',
activeSource: 'Active subscription source',
activeSourceSelf: 'Self-hosted node',
activeSourcePool: 'Backup pool',
timeline: 'Recent event timeline (newest first)',
noTimeline: '(No noteworthy recent events)',
selfHealActions: 'Self-healing actions attempted so far',
noSelfHealActions: '(No automatic restart/switch has been triggered)',
restartAttemptedToday: (n) => `${n} restart attempt(s) today`,
restartSucceededToday: (n) => `of which ${n} restart command(s) actually succeeded`,
aiDiagnosis: 'Latest AI diagnosis',
noAiDiagnosis: '(AI diagnosis disabled, or never triggered)',
aiDiagnosisAt: 'Diagnosed at',
aiDiagnosisFailed: 'AI diagnosis call itself failed',
kbSuggestions: 'Matched knowledge base suggestions',
noKbSuggestions: '(No knowledge base entries matched recent events)',
footer: '(Auto-generated by NodeNanny — feel free to paste this into a forum post when asking for help)',
events: {
down: () => 'Node TCP port check failed (marked down)',
recovered: (p) => p.downMinutes != null ? `Node recovered (was down for about ${p.downMinutes} min)` : 'Node recovered',
restart: (p) => `Attempted to restart service ${p.serviceName || ''}`.trim(),
restart_failed: (p) => `Restart command failed${p.error ? ': ' + p.error : ''}`,
usability_down: (p) => `Layer-2 usability check marked down${p.detail ? ' (' + p.detail + ')' : ''}`,
usability_ok: () => 'Layer-2 usability check recovered',
usability_check_error: (p) => `Layer-2 usability check mechanism itself errored${p.detail ? ' (' + p.detail + ')' : ''}`,
usability_restart_suggested: () => 'Repeated failures — sent a "manual restart suggested" notice (not auto-executed)',
usability_auto_restart: (p) => `Repeated failures — auto-triggered a restart (${p.restartOk ? 'succeeded' : 'failed'})`,
pool_activated: (p) => `Switched to backup pool (${p.count || 0} node(s) available)`,
pool_deactivated: () => 'Switched back from backup pool to self-hosted node',
ai_diagnosis: () => 'Triggered an AI diagnosis',
ai_diagnosis_failed: (p) => `AI diagnosis call failed${p.error ? ': ' + p.error : ''}`
}
},
ja: {
title: 'NodeNanny 障害レポート',
generatedAt: '生成日時',
nodeName: 'ノード名',
tcpStatus: 'TCP生存状態',
usabilityStatus: '二層可用性状態',
since: '~から',
statusOk: '正常',
statusDown: 'ダウン',
statusUnknown: '不明',
usabilityOk: '正常',
usabilityDown: '異常',
usabilityCheckError: '検査手段自体のエラー',
usabilityDisabled: '無効/データなし',
activeSource: '現在の購読ソース',
activeSourceSelf: '自前ノード',
activeSourcePool: '予備プール',
timeline: '最近のイベントタイムライン(新しい順)',
noTimeline: '(特筆すべき最近のイベントはありません)',
selfHealActions: 'これまでに試みた自己修復アクション',
noSelfHealActions: '(自動再起動/切り替えはまだ発生していません)',
restartAttemptedToday: (n) => `本日累計 ${n} 回の再起動を試行`,
restartSucceededToday: (n) => `うち ${n} 回は実際に成功`,
aiDiagnosis: '直近のAI診断結果',
noAiDiagnosis: '(AI診断は無効、またはまだ発動していません)',
aiDiagnosisAt: '診断時刻',
aiDiagnosisFailed: 'AI診断の呼び出し自体が失敗',
kbSuggestions: 'ナレッジベースの関連提案',
noKbSuggestions: '(最近のイベントに一致するナレッジベース項目はありません)',
footer: '(このレポートはNodeNannyが自動生成しました。フォーラムへの投稿にそのまま貼り付けて構いません)',
events: {
down: () => 'ノードのTCPポート検査に失敗しました(ダウン判定)',
recovered: (p) => p.downMinutes != null ? `ノードが復旧しました(約 ${p.downMinutes} 分間ダウンしていました)` : 'ノードが復旧しました',
restart: (p) => `サービス ${p.serviceName || ''} の再起動を試行`.trim(),
restart_failed: (p) => `再起動コマンドが失敗しました${p.error ? ':' + p.error : ''}`,
usability_down: (p) => `二層可用性検査が異常と判定${p.detail ? '(' + p.detail + ')' : ''}`,
usability_ok: () => '二層可用性検査が正常に復旧',
usability_check_error: (p) => `二層可用性検査の手段自体でエラー${p.detail ? '(' + p.detail + ')' : ''}`,
usability_restart_suggested: () => '連続失敗のため「手動再起動を推奨」の通知を送信済み(自動実行はされていません)',
usability_auto_restart: (p) => `連続失敗のため自動再起動を実行(${p.restartOk ? '成功' : '失敗'})`,
pool_activated: (p) => `予備プールに切り替えました(利用可能ノード ${p.count || 0} 件)`,
pool_deactivated: () => '予備プールから自前ノードに切り戻しました',
ai_diagnosis: () => 'AI診断を実行しました',
ai_diagnosis_failed: (p) => `AI診断の呼び出しに失敗しました${p.error ? ':' + p.error : ''}`
}
},
de: {
title: 'NodeNanny Ausfallbericht',
generatedAt: 'Erstellt am',
nodeName: 'Knotenname',
tcpStatus: 'TCP-Erreichbarkeitsstatus',
usabilityStatus: 'Layer-2-Nutzbarkeitsstatus',
since: 'seit',
statusOk: 'OK',
statusDown: 'Ausgefallen',
statusUnknown: 'Unbekannt',
usabilityOk: 'OK',
usabilityDown: 'Ausgefallen',
usabilityCheckError: 'Prüfmechanismus selbst fehlerhaft',
usabilityDisabled: 'Deaktiviert / keine Daten',
activeSource: 'Aktive Abo-Quelle',
activeSourceSelf: 'Selbst gehosteter Knoten',
activeSourcePool: 'Backup-Pool',
timeline: 'Ereignis-Zeitleiste (neueste zuerst)',
noTimeline: '(Keine nennenswerten kürzlichen Ereignisse)',
selfHealActions: 'Bisher versuchte Selbstheilungs-Maßnahmen',
noSelfHealActions: '(Noch kein automatischer Neustart/Wechsel ausgelöst)',
restartAttemptedToday: (n) => `${n} Neustartversuch(e) heute`,
restartSucceededToday: (n) => `davon ${n} tatsächlich erfolgreich`,
aiDiagnosis: 'Letzte KI-Diagnose',
noAiDiagnosis: '(KI-Diagnose deaktiviert oder nie ausgelöst)',
aiDiagnosisAt: 'Diagnosezeit',
aiDiagnosisFailed: 'KI-Diagnoseaufruf selbst fehlgeschlagen',
kbSuggestions: 'Passende Vorschläge aus der Wissensdatenbank',
noKbSuggestions: '(Keine passenden Wissensdatenbank-Einträge zu jüngsten Ereignissen)',
footer: '(Automatisch von NodeNanny erstellt — kann direkt in einen Forenbeitrag eingefügt werden)',
events: {
down: () => 'TCP-Port-Prüfung des Knotens fehlgeschlagen (als ausgefallen markiert)',
recovered: (p) => p.downMinutes != null ? `Knoten wiederhergestellt (war ca. ${p.downMinutes} Min. ausgefallen)` : 'Knoten wiederhergestellt',
restart: (p) => `Neustart des Dienstes ${p.serviceName || ''} versucht`.trim(),
restart_failed: (p) => `Neustartbefehl fehlgeschlagen${p.error ? ': ' + p.error : ''}`,
usability_down: (p) => `Layer-2-Nutzbarkeitsprüfung als ausgefallen markiert${p.detail ? ' (' + p.detail + ')' : ''}`,
usability_ok: () => 'Layer-2-Nutzbarkeitsprüfung wiederhergestellt',
usability_check_error: (p) => `Layer-2-Prüfmechanismus selbst fehlerhaft${p.detail ? ' (' + p.detail + ')' : ''}`,
usability_restart_suggested: () => 'Wiederholte Fehlschläge — Hinweis "manueller Neustart empfohlen" gesendet (nicht automatisch ausgeführt)',
usability_auto_restart: (p) => `Wiederholte Fehlschläge — automatischer Neustart ausgelöst (${p.restartOk ? 'erfolgreich' : 'fehlgeschlagen'})`,
pool_activated: (p) => `Auf Backup-Pool umgeschaltet (${p.count || 0} Knoten verfügbar)`,
pool_deactivated: () => 'Vom Backup-Pool zurück zum selbst gehosteten Knoten gewechselt',
ai_diagnosis: () => 'KI-Diagnose ausgelöst',
ai_diagnosis_failed: (p) => `KI-Diagnoseaufruf fehlgeschlagen${p.error ? ': ' + p.error : ''}`
}
},
ru: {
title: 'Отчёт об сбое NodeNanny',
generatedAt: 'Сформирован',
nodeName: 'Имя узла',
tcpStatus: 'Статус доступности по TCP',
usabilityStatus: 'Статус проверки 2-го уровня',
since: 'с',
statusOk: 'В норме',
statusDown: 'Недоступен',
statusUnknown: 'Неизвестно',
usabilityOk: 'В норме',
usabilityDown: 'Сбой',
usabilityCheckError: 'Ошибка самого механизма проверки',
usabilityDisabled: 'Отключено / нет данных',
activeSource: 'Текущий источник подписки',
activeSourceSelf: 'Собственный узел',
activeSourcePool: 'Резервный пул',
timeline: 'Хронология последних событий (сначала новые)',
noTimeline: '(Нет значимых недавних событий)',
selfHealActions: 'Уже предпринятые действия самовосстановления',
noSelfHealActions: '(Автоматический перезапуск/переключение ещё не срабатывали)',
restartAttemptedToday: (n) => `Попыток перезапуска сегодня: ${n}`,
restartSucceededToday: (n) => `из них успешно: ${n}`,
aiDiagnosis: 'Последний вывод AI-диагностики',
noAiDiagnosis: '(AI-диагностика отключена или ещё не запускалась)',
aiDiagnosisAt: 'Время диагностики',
aiDiagnosisFailed: 'Сам вызов AI-диагностики завершился ошибкой',
kbSuggestions: 'Найденные рекомендации из базы знаний',
noKbSuggestions: '(Подходящих записей базы знаний для недавних событий не найдено)',
footer: '(Отчёт сформирован автоматически NodeNanny — можно вставить прямо в форумное сообщение)',
events: {
down: () => 'Проверка TCP-порта узла не пройдена (статус: недоступен)',
recovered: (p) => p.downMinutes != null ? `Узел восстановлен (был недоступен примерно ${p.downMinutes} мин.)` : 'Узел восстановлен',
restart: (p) => `Попытка перезапуска службы ${p.serviceName || ''}`.trim(),
restart_failed: (p) => `Команда перезапуска завершилась ошибкой${p.error ? ': ' + p.error : ''}`,
usability_down: (p) => `Проверка 2-го уровня показала сбой${p.detail ? ' (' + p.detail + ')' : ''}`,
usability_ok: () => 'Проверка 2-го уровня восстановлена',
usability_check_error: (p) => `Сам механизм проверки 2-го уровня дал сбой${p.detail ? ' (' + p.detail + ')' : ''}`,
usability_restart_suggested: () => 'Повторные сбои — отправлено уведомление "рекомендуется ручной перезапуск" (не выполнено автоматически)',
usability_auto_restart: (p) => `Повторные сбои — автоматически запущен перезапуск (${p.restartOk ? 'успешно' : 'не удалось'})`,
pool_activated: (p) => `Переключено на резервный пул (доступно узлов: ${p.count || 0})`,
pool_deactivated: () => 'Переключено обратно с резервного пула на собственный узел',
ai_diagnosis: () => 'Запущена AI-диагностика',
ai_diagnosis_failed: (p) => `Вызов AI-диагностики завершился ошибкой${p.error ? ': ' + p.error : ''}`
}
}
};
function formatTime(iso) {
if (!iso) return '';
return new Date(iso).toISOString().replace('T', ' ').replace(/\.\d+Z$/, ' UTC');
}
// KB条目的title从纯字符串改成了{zh,en,ja,de,ru}多语言对象(跟explanation结构一致);
// 兼容旧数据/还没跟进的远程同步条目仍是字符串的情况,直接原样返回。
function localizedTitle(title, lang) {
if (!title) return '';
if (typeof title === 'string') return title;
return title[lang] || title.zh || title.en || '';
}
// 从最近事件里去重收集kb建议——同一个kb条目在时间线里出现多次(比如反复触发
// usability_restart_suggested)只展示一次,避免报告里重复的大段文字。
function collectKbSuggestions(timelineEvents, lang) {
const seen = new Map();
for (const ev of timelineEvents) {
const hits = ev.params && Array.isArray(ev.params.kb) ? ev.params.kb : [];
for (const entry of hits) {
if (!entry || !entry.id || seen.has(entry.id)) continue;
const explanation = (entry.explanation && (entry.explanation[lang] || entry.explanation.en)) || '';
const title = localizedTitle(entry.title, lang) || entry.id;
seen.set(entry.id, { title, explanation, riskLevel: entry.riskLevel });
}
}
return Array.from(seen.values());
}
// 纯函数:只吃已经拿到手的 state/events,不碰 store.js 的真实文件读写,
// 方便单测(跟checker.js把shouldSuggestUsability拆成纯函数是同一个思路)。
function assembleReportText({ config, lang, state, events } = {}) {
const L = normalizeLang(lang);
const d = I18N[L];
const relevantEvents = (events || []).filter((ev) => RELEVANT_EVENT_TYPES.has(ev.type));
const timelineEvents = relevantEvents.slice(0, 15); // 最近15条相关事件足够描述清楚最近发生了什么,太多反而淹没重点
const node = (config && config.node) || {};
const lines = [];
lines.push(d.title);
lines.push(`${d.generatedAt}: ${formatTime(new Date().toISOString())}`);
lines.push('');
lines.push(`${d.nodeName}: ${node.name || '-'}`);
const tcpStatusLabel = state.status === 'ok' ? d.statusOk : state.status === 'down' ? d.statusDown : d.statusUnknown;
lines.push(`${d.tcpStatus}: ${tcpStatusLabel}${state.since ? ` (${d.since} ${formatTime(state.since)})` : ''}`);
let usabilityLabel = d.usabilityDisabled;
if (state.usabilityStatus === 'ok') usabilityLabel = d.usabilityOk;
else if (state.usabilityStatus === 'down') usabilityLabel = d.usabilityDown;
else if (state.usabilityStatus === 'check_error') usabilityLabel = d.usabilityCheckError;
lines.push(`${d.usabilityStatus}: ${usabilityLabel}`);
const sourceLabel = state.activeSource === 'pool' ? d.activeSourcePool : d.activeSourceSelf;
lines.push(`${d.activeSource}: ${sourceLabel}`);
lines.push('');
lines.push(d.timeline);
if (timelineEvents.length === 0) {
lines.push(d.noTimeline);
} else {
for (const ev of timelineEvents) {
const renderer = d.events[ev.type];
const text = renderer ? renderer(ev.params || {}) : ev.type;
lines.push(`- [${formatTime(ev.time)}] ${text}`);
}
}
lines.push('');
lines.push(d.selfHealActions);
const restartToday = state.restartsToday || 0;
const restartSuccessToday = state.restartsSuccessToday || 0;
if (restartToday === 0) {
lines.push(d.noSelfHealActions);
} else {
lines.push(`- ${d.restartAttemptedToday(restartToday)}`);
lines.push(`- ${d.restartSucceededToday(restartSuccessToday)}`);
}
lines.push('');
lines.push(d.aiDiagnosis);
if (state.lastDiagnosis && (state.lastDiagnosis.text || state.lastDiagnosis.error)) {
lines.push(`(${d.aiDiagnosisAt}: ${formatTime(state.lastDiagnosis.at)})`);
if (state.lastDiagnosis.error) {
lines.push(`${d.aiDiagnosisFailed}: ${state.lastDiagnosis.error}`);
} else {
lines.push(state.lastDiagnosis.text);
}
} else {
lines.push(d.noAiDiagnosis);
}
lines.push('');
lines.push(d.kbSuggestions);
const kbSuggestions = collectKbSuggestions(timelineEvents, L);
if (kbSuggestions.length === 0) {
lines.push(d.noKbSuggestions);
} else {
for (const s of kbSuggestions) {
lines.push(`- ${s.title}${s.riskLevel ? ` [${s.riskLevel}]` : ''}: ${s.explanation}`);
}
}
lines.push('');
lines.push(d.footer);
return lines.join('\n');
}
// 真正对外的入口:读取store.js里的真实state/events,交给上面的纯函数组装。
function buildFailureReport({ config, lang } = {}) {
const state = store.getState();
const events = store.getEvents(200);
return assembleReportText({ config, lang, state, events });
}
module.exports = {
buildFailureReport,
normalizeLang,
SUPPORTED_LANGS,
_internal: { assembleReportText, collectKbSuggestions, formatTime }
};