码桶

发现社区成员的开源项目

main
NodeNanny/public/pool-events.html
pool-events.html21.1 KB
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NodeNanny · 流量池检测明细</title>
<link rel="icon" type="image/png" href="/assets/favicon.png?v=2">
<link rel="shortcut icon" type="image/png" href="/assets/favicon.png?v=2">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Fraunces:ital,opsz,wght@0,9..144,500;0,9..144,600;1,9..144,500&family=IBM+Plex+Sans:wght@400;500;600&family=IBM+Plex+Mono:wght@400;500&display=swap" rel="stylesheet">
<style>
  /* ---------- UI改版:流量池检测明细独立页面 ----------
     此前这张卡片挤在主面板index.html里,内容一多就把整个面板拉得很长。
     创始人在"折叠收起/只留最新几轮/独立页面"三个方案里选了独立页面。
     视觉风格(配色变量、字体、topbar、.log卡片的账本风格)跟index.html
     完全沿用一致,不另起一套设计语言——只是把.pool-events-*这部分内容
     单独装进这个文件,逻辑跟此前在index.html里的实现完全相同。 */
  :root {
    --bg-a: #121a1c;
    --bg-b: #0c1214;
    --surface: #161e21;
    --surface-2: #1a2124;
    --hairline: #2b3538;
    --text: #e4dcc8;
    --text-muted: #8a8f8a;
    --brass: #c8963e;
    --brass-soft: #e0b667;
    --brass-dim: #7d6534;
    --teal: #4d8478;
    --teal-soft: #7fada4;
    --rust: #c05a44;
    --rust-soft: #dd8468;
    --unknown: #5b6265;
    --mortar: rgba(200, 150, 62, 0.07);
  }

  * { box-sizing: border-box; }

  html, body {
    margin: 0;
    min-height: 100vh;
    background:
      radial-gradient(ellipse 1100px 640px at 78% -8%, #1c2620 0%, transparent 55%),
      linear-gradient(180deg, var(--bg-a) 0%, var(--bg-b) 100%);
    color: var(--text);
    font-family: 'IBM Plex Sans', -apple-system, 'PingFang SC', 'Hiragino Sans', 'Yu Gothic',
      'Microsoft YaHei', sans-serif;
    -webkit-font-smoothing: antialiased;
  }

  body {
    display: flex;
    justify-content: center;
    padding: 40px 20px 90px;
  }

  .page { width: 100%; max-width: 820px; }

  .topbar {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 28px;
    flex-wrap: wrap;
    gap: 12px;
  }
  .brand { display: flex; align-items: center; gap: 10px; }
  .brand svg { width: 26px; height: 26px; flex-shrink: 0; }
  .brand-word {
    font-family: 'Fraunces', serif;
    font-weight: 500;
    font-size: 18px;
    letter-spacing: 0.01em;
  }
  .topbar-nav { display: flex; align-items: center; gap: 10px; }
  .nav-link {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 13px;
    color: var(--text-muted);
    padding: 5px 2px 6px;
    text-decoration: none;
    white-space: nowrap;
    border-bottom: 2px solid transparent;
    transition: color 0.15s, border-color 0.15s;
  }
  .nav-link svg { width: 14px; height: 14px; flex-shrink: 0; opacity: 0.85; }
  .nav-link:hover { color: var(--brass-soft); border-bottom-color: var(--brass-dim); text-decoration: none; }
  .lang-switch { display: flex; align-items: center; }
  .lang-switch button {
    font-family: 'IBM Plex Mono', monospace;
    font-size: 12px;
    letter-spacing: 0.04em;
    background: transparent;
    border: none;
    border-right: 1px solid var(--hairline);
    color: var(--text-muted);
    padding: 4px 11px;
    cursor: pointer;
    transition: color 0.15s ease;
  }
  .lang-switch button:last-child { border-right: none; }
  .lang-switch button:hover { color: var(--text); }
  .lang-switch button.active { color: var(--brass-soft); border-bottom: 1px solid var(--brass); }
  .lang-switch button:focus-visible { outline: 2px solid var(--brass); outline-offset: 1px; }

  .page-title {
    font-family: 'Fraunces', serif;
    font-style: italic;
    font-weight: 500;
    font-size: 22px;
    margin: 0 0 8px;
  }
  .page-lead {
    font-size: 13.5px;
    color: var(--text-muted);
    line-height: 1.6;
    margin: 0 0 26px;
  }

  .log {
    margin-top: 0;
    background: var(--surface);
    border: 1px solid var(--hairline);
    border-radius: 6px;
    padding: 26px 32px 16px;
  }
  .empty-log { color: var(--text-muted); font-size: 13.5px; padding: 6px 0 22px; }

  .pool-events-round { padding: 13px 0; border-top: 1px solid var(--hairline); }
  .pool-events-round:first-of-type { border-top: none; }
  .pool-events-round-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 4px 10px;
    font-size: 12.5px;
    color: var(--text-muted);
    margin-bottom: 6px;
  }
  .pool-events-round-source { color: var(--text); font-family: 'IBM Plex Mono', monospace; }
  .pool-events-round-time { font-family: 'IBM Plex Mono', monospace; font-variant-numeric: tabular-nums; }
  .pool-events-round-tally { font-family: 'IBM Plex Mono', monospace; }
  .pool-events-item {
    display: grid;
    grid-template-columns: 70px 1fr auto;
    gap: 0 10px;
    align-items: baseline;
    padding: 5px 0;
    font-size: 12.5px;
  }
  .pool-events-item[data-clickable="1"] { cursor: pointer; }
  .pool-events-item[data-clickable="1"]:hover .pool-events-outcome { text-decoration: underline; }
  .pool-events-proto { color: var(--text-muted); font-family: 'IBM Plex Mono', monospace; text-transform: uppercase; font-size: 11px; }
  .pool-events-host { color: var(--text); font-family: 'IBM Plex Mono', monospace; overflow-wrap: break-word; }
  .pool-events-outcome { font-family: 'IBM Plex Mono', monospace; white-space: nowrap; }
  .pool-events-item.outcome-ok .pool-events-outcome { color: var(--teal-soft); }
  .pool-events-item.outcome-down .pool-events-outcome { color: var(--rust-soft); }
  .pool-events-item.outcome-check_error .pool-events-outcome { color: var(--brass); }
  .pool-events-item.outcome-unsupported .pool-events-outcome { color: var(--text-muted); }
  .pool-events-explain {
    grid-column: 1 / -1;
    font-size: 12px;
    color: var(--text-muted);
    background: rgba(0,0,0,0.14);
    border-left: 2px solid var(--brass-dim);
    padding: 6px 10px;
    margin-top: 4px;
  }

  @media (max-width: 640px) {
    .log { padding: 20px 18px 10px; }
    .topbar { flex-direction: column; align-items: flex-start; }
  }
</style>
</head>
<body>
  <div class="page">
    <div class="topbar">
      <div class="brand">
        <svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
          <path d="M14.5 3 L17.5 3 L19 10 L13 10 Z" fill="var(--brass)"/>
          <path d="M13.3 10.5 H18.7 L20.5 24 H11.5 Z" fill="none" stroke="var(--text-muted)" stroke-width="1.1"/>
          <line x1="12.2" y1="15" x2="19.8" y2="15" stroke="var(--text-muted)" stroke-width="1"/>
          <line x1="11.8" y1="19.5" x2="20.2" y2="19.5" stroke="var(--text-muted)" stroke-width="1"/>
          <line x1="6" y1="24" x2="26" y2="24" stroke="var(--text-muted)" stroke-width="1.2"/>
        </svg>
        <span class="brand-word">NodeNanny</span>
      </div>
      <div class="topbar-nav">
        <a class="nav-link" href="/index.html">
          <svg viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
            <path d="M2.5 8 L8 3 L13.5 8" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
            <path d="M4 7 V13 H12 V7" stroke="currentColor" stroke-width="1.1" stroke-linejoin="round"/>
          </svg>
          <span id="back-label"></span>
        </a>
        <div class="lang-switch" id="lang-switch" role="group" aria-label="Language"></div>
      </div>
    </div>

    <h1 class="page-title" id="page-title"></h1>
    <p class="page-lead" id="page-lead"></p>

    <div class="log">
      <div id="pool-events-list"></div>
      <p class="empty-log" id="pool-events-empty" style="display:none;"></p>
    </div>
  </div>

<script>
  const I18N = {
    zh: {
      code: '中', timeZone: 'Asia/Shanghai',
      backLabel: '← 返回主面板',
      pageTitle: '流量池检测明细',
      pageLead: '每一轮流量池刷新时,各个来源产出的候选节点分别通过三层检测(存活/测速/真实性)的结果,按轮次列在这里。点击带说明的记录可以查看知识库解释。',
      poolEventsEmpty: '流量池未开启,或者还没有可展示的检测记录——第一轮刷新跑完之后才会有数据,不代表出了故障。',
      poolEventsRoundTally: (passed, total) => `${passed}/${total} 通过`,
      poolEventsOutcome: { ok: '✓ 可用', down: '✕ 判定不可用', check_error: '! 检测环境问题', unsupported: '– 协议不支持', unknown: '? 未知' },
      poolEventsProtocolUnknown: '未知协议',
      poolEventsHostUnknown: '未知地址',
      poolEventsExplainHint: '点击查看知识库解释',
      poolEventsExplainLoading: '查询中…',
      poolEventsExplainEmpty: '知识库暂无对应词条说明',
      poolEventsExplainError: '查询失败,请稍后再试',
    },
    en: {
      code: 'EN', timeZone: 'UTC',
      backLabel: '← Back to dashboard',
      pageTitle: 'Backup pool check details',
      pageLead: 'Each time the backup pool refreshes, the candidate nodes each source produced go through three detection layers (alive / speed / authenticity). Results are listed here by round. Click an entry with an explanation to see the matching knowledge base note.',
      poolEventsEmpty: "The backup pool is off, or there's no check log to show yet — data appears after the first refresh cycle finishes; this isn't a sign of trouble.",
      poolEventsRoundTally: (passed, total) => `${passed}/${total} passed`,
      poolEventsOutcome: { ok: '✓ usable', down: '✕ judged unusable', check_error: '! detection environment issue', unsupported: '– protocol unsupported', unknown: '? unknown' },
      poolEventsProtocolUnknown: 'unknown protocol',
      poolEventsHostUnknown: 'unknown address',
      poolEventsExplainHint: 'Click for knowledge base explanation',
      poolEventsExplainLoading: 'Looking up…',
      poolEventsExplainEmpty: 'No matching entry in the knowledge base',
      poolEventsExplainError: 'Lookup failed, please try again later',
    },
    ja: {
      code: '日', timeZone: 'Asia/Tokyo',
      backLabel: '← メインパネルに戻る',
      pageTitle: '予備プール検査明細',
      pageLead: '予備プールが更新されるたびに、各ソースが出した候補ノードが三層の検査(生存/速度/真正性)を通過したかどうかを、更新回ごとにここに一覧表示します。説明付きの項目をクリックするとナレッジベースの説明が表示されます。',
      poolEventsEmpty: '予備プールが無効か、まだ表示できる検査記録がありません——初回の更新サイクルが完了すると表示されます。故障ではありません。',
      poolEventsRoundTally: (passed, total) => `${passed}/${total} 件通過`,
      poolEventsOutcome: { ok: '✓ 利用可能', down: '✕ 利用不可と判定', check_error: '! 検査環境の問題', unsupported: '– プロトコル非対応', unknown: '? 不明' },
      poolEventsProtocolUnknown: '不明なプロトコル',
      poolEventsHostUnknown: '不明なアドレス',
      poolEventsExplainHint: 'クリックしてナレッジベースの説明を見る',
      poolEventsExplainLoading: '検索中…',
      poolEventsExplainEmpty: 'ナレッジベースに該当する項目がありません',
      poolEventsExplainError: '検索に失敗しました。しばらくしてから再試行してください',
    },
    de: {
      code: 'DE', timeZone: 'Europe/Berlin',
      backLabel: '← Zurück zum Bedienfeld',
      pageTitle: 'Prüfdetails des Notfall-Pools',
      pageLead: 'Bei jeder Aktualisierung des Notfall-Pools durchlaufen die von jeder Quelle gelieferten Kandidatenknoten drei Prüfebenen (Erreichbarkeit / Geschwindigkeit / Echtheit). Die Ergebnisse werden hier nach Durchlauf aufgelistet. Klicke auf einen Eintrag mit Erklärung, um die passende Notiz aus der Wissensdatenbank zu sehen.',
      poolEventsEmpty: 'Der Notfall-Pool ist deaktiviert, oder es gibt noch kein Prüfprotokoll — Daten erscheinen nach dem ersten Aktualisierungszyklus; das ist kein Anzeichen für eine Störung.',
      poolEventsRoundTally: (passed, total) => `${passed}/${total} bestanden`,
      poolEventsOutcome: { ok: '✓ nutzbar', down: '✕ als nicht nutzbar eingestuft', check_error: '! Problem der Prüfumgebung', unsupported: '– Protokoll nicht unterstützt', unknown: '? unbekannt' },
      poolEventsProtocolUnknown: 'unbekanntes Protokoll',
      poolEventsHostUnknown: 'unbekannte Adresse',
      poolEventsExplainHint: 'Klicken für Erklärung aus der Wissensdatenbank',
      poolEventsExplainLoading: 'Wird gesucht…',
      poolEventsExplainEmpty: 'Kein passender Eintrag in der Wissensdatenbank',
      poolEventsExplainError: 'Abfrage fehlgeschlagen, bitte später erneut versuchen',
    },
    ru: {
      code: 'RU', timeZone: 'Europe/Moscow',
      backLabel: '← Назад на главную панель',
      pageTitle: 'Детали проверки резервного пула',
      pageLead: 'При каждом обновлении резервного пула узлы-кандидаты от каждого источника проходят три уровня проверки (доступность / скорость / подлинность). Результаты перечислены здесь по циклам. Нажмите на запись с пояснением, чтобы увидеть соответствующую заметку из базы знаний.',
      poolEventsEmpty: 'Резервный пул выключен, либо пока нет журнала проверок — данные появятся после первого цикла обновления, это не признак сбоя.',
      poolEventsRoundTally: (passed, total) => `${passed}/${total} пройдено`,
      poolEventsOutcome: { ok: '✓ пригоден', down: '✕ признан непригодным', check_error: '! проблема среды проверки', unsupported: '– протокол не поддерживается', unknown: '? неизвестно' },
      poolEventsProtocolUnknown: 'неизвестный протокол',
      poolEventsHostUnknown: 'неизвестный адрес',
      poolEventsExplainHint: 'Нажмите, чтобы увидеть объяснение из базы знаний',
      poolEventsExplainLoading: 'Поиск…',
      poolEventsExplainEmpty: 'В базе знаний нет подходящей записи',
      poolEventsExplainError: 'Не удалось выполнить запрос, попробуйте позже',
    },
  };

  // 语言状态跟主面板共用同一个localStorage键,两个页面之间切换语言时体验一致。
  let lang = localStorage.getItem('nodenanny-lang') || (navigator.language || 'zh').slice(0,2);
  if (!I18N[lang]) lang = 'en';
  let latestPoolEventsData = null;

  function t() { return I18N[lang]; }

  function buildLangSwitch() {
    const el = document.getElementById('lang-switch');
    el.innerHTML = Object.keys(I18N).map(code =>
      `<button data-lang="${code}" class="${code === lang ? 'active' : ''}">${I18N[code].code}</button>`
    ).join('');
    el.querySelectorAll('button').forEach(btn => {
      btn.addEventListener('click', () => {
        lang = btn.dataset.lang;
        localStorage.setItem('nodenanny-lang', lang);
        document.documentElement.lang = lang;
        buildLangSwitch();
        renderStatic();
        if (latestPoolEventsData) renderPoolEvents(latestPoolEventsData);
      });
    });
  }

  function renderStatic() {
    const d = t();
    document.getElementById('back-label').textContent = d.backLabel;
    document.getElementById('page-title').textContent = d.pageTitle;
    document.getElementById('page-lead').textContent = d.pageLead;
    document.getElementById('pool-events-empty').textContent = d.poolEventsEmpty;
  }

  function localTimeOptions(extra) {
    const tz = (I18N[lang] && I18N[lang].timeZone) || 'UTC';
    return Object.assign({ timeZone: tz, hour12: false }, extra || {});
  }

  function formatClock(iso) {
    if (!iso) return '—';
    return new Date(iso).toLocaleTimeString(lang, localTimeOptions({ hour: '2-digit', minute: '2-digit' }));
  }

  function escapeHtml(str) {
    return String(str == null ? '' : str).replace(/[&<>"']/g, (c) => (
      { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]
    ));
  }

  function poolEventOutcomeLabel(outcome) {
    const d = t();
    return (d.poolEventsOutcome && d.poolEventsOutcome[outcome]) || d.poolEventsOutcome.unknown;
  }

  // data 是 /api/pool/events 返回的原始数据({ enabled, rounds: [{ at, sourceId,
  // candidateCount, passedCount, results: [{ index, protocol, host, port, outcome, code, detail }] }] })。
  // 每一项如果带 code,点击可以展开/收起一段知识库解释(懒加载,见下面的点击代理监听)。
  // 这套渲染逻辑跟此前挂在主面板index.html里的实现完全一致,只是搬到了这个独立页面。
  function renderPoolEvents(data) {
    const d = t();
    const list = document.getElementById('pool-events-list');
    const emptyBox = document.getElementById('pool-events-empty');

    const rounds = (data && data.rounds) || [];
    if (rounds.length === 0) {
      list.innerHTML = '';
      emptyBox.style.display = '';
      emptyBox.textContent = d.poolEventsEmpty;
      return;
    }
    emptyBox.style.display = 'none';

    list.innerHTML = rounds.map((round) => {
      const itemsHtml = (round.results || []).map((r) => {
        const proto = r.protocol ? escapeHtml(r.protocol) : d.poolEventsProtocolUnknown;
        const hostText = r.host ? `${escapeHtml(r.host)}${r.port ? ':' + r.port : ''}` : d.poolEventsHostUnknown;
        const outcomeLabel = poolEventOutcomeLabel(r.outcome);
        // 成功(ok)结果的code(如POOL_ALIVE_OK/POOL_SPEED_OK/POOL_AUTHENTIC_OK)是故意
        // 不配知识库词条的——点开只会显示"无匹配词条",容易让人误以为知识库漏配了。
        // 只有非ok结果才值得点开看解释,这样"可点击"这件事本身就能提示用户"这里有问题"。
        const clickable = (r.code && r.outcome !== 'ok') ? '1' : '0';
        return `
          <div class="pool-events-item outcome-${escapeHtml(r.outcome || 'unknown')}" data-clickable="${clickable}" data-round-at="${escapeHtml(round.at)}" data-index="${r.index}" ${clickable === '1' ? `title="${escapeHtml(d.poolEventsExplainHint)}"` : ''}>
            <span class="pool-events-proto">${proto}</span>
            <span class="pool-events-host">${hostText}</span>
            <span class="pool-events-outcome">${outcomeLabel}</span>
          </div>`;
      }).join('');
      return `
        <div class="pool-events-round">
          <div class="pool-events-round-head">
            <span class="pool-events-round-source">${escapeHtml(round.sourceId)}</span>
            <span class="pool-events-round-time">${formatClock(round.at)}</span>
            <span class="pool-events-round-tally">${d.poolEventsRoundTally(round.passedCount, round.candidateCount)}</span>
          </div>
          ${itemsHtml}
        </div>`;
    }).join('');
  }

  async function loadPoolEvents() {
    try {
      const res = await fetch('/api/pool/events');
      if (res.status === 401) { window.location.href = '/login.html'; return; }
      const data = await res.json();
      latestPoolEventsData = data;
      renderPoolEvents(data);
    } catch (err) {
      // 拿不到检测明细就保留上一次成功渲染的画面,静默失败
    }
  }

  document.getElementById('pool-events-list').addEventListener('click', async (e) => {
    const item = e.target.closest('.pool-events-item');
    if (!item || item.dataset.clickable !== '1') return;

    const existing = item.querySelector('.pool-events-explain');
    if (existing) {
      existing.remove(); // 再点一次收起,不重新请求
      return;
    }

    const d = t();
    const explainBox = document.createElement('div');
    explainBox.className = 'pool-events-explain';
    item.appendChild(explainBox);

    // 本轮修复:知识库解释现在是recordRound()写入事件时就自动匹配好的(见pool-events.js
    // 的attachKbMatches),不用点开才发请求——latestPoolEventsData里每条非ok结果都已经
    // 带着r.kb这个字段了,这里只是从内存里把它找出来渲染,不再fetch。
    const roundAt = item.dataset.roundAt;
    const index = Number(item.dataset.index);
    const round = ((latestPoolEventsData && latestPoolEventsData.rounds) || []).find((rd) => rd.at === roundAt);
    const r = round && (round.results || []).find((res) => res.index === index);
    const hits = (r && r.kb) || [];
    const text = hits
      .map((h) => (h.explanation && (h.explanation[lang] || h.explanation.en)) || '')
      .filter(Boolean)
      .join('\n');
    explainBox.textContent = text || d.poolEventsExplainEmpty;
  });

  document.documentElement.lang = lang;
  buildLangSwitch();
  renderStatic();
  loadPoolEvents();
  setInterval(loadPoolEvents, 60000);
</script>
</body>
</html>