码桶
发现社区成员的开源项目
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) => (
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>