码桶

发现社区成员的开源项目

main
NodeNanny/core/failure-report.js
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 }
};