608 lines
35 KiB
HTML
608 lines
35 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head><meta charset="utf-8"><title>MoFin 健康监控</title>
|
||
<style>
|
||
*{margin:0;padding:0;box-sizing:border-box;font-family:system-ui,-apple-system,sans-serif}
|
||
body{background:#0d1117;color:#c9d1d9;padding:20px}
|
||
h1{font-size:22px;margin-bottom:16px;color:#58a6ff}
|
||
.tabs{display:flex;gap:4px;margin-bottom:16px;border-bottom:1px solid #30363d;flex-wrap:wrap}
|
||
.tab{padding:8px 20px;cursor:pointer;border:1px solid transparent;border-radius:4px 4px 0 0;color:#8b949e;font-size:14px}
|
||
.tab.active{background:#161b22;border-color:#30363d #30363d #161b22;color:#c9d1d9}
|
||
.tab:hover{color:#58a6ff}
|
||
.panel{display:none}
|
||
.panel.active{display:block}
|
||
.badge{display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px}
|
||
.badge.ok{background:#3fb950}
|
||
.badge.warn{background:#d29922}
|
||
.badge.fail{background:#f85149}
|
||
table{width:100%;border-collapse:collapse;font-size:13px;margin-bottom:20px}
|
||
th,td{padding:5px 8px;text-align:left;border-bottom:1px solid #21262d;white-space:nowrap}
|
||
th{background:#161b22;color:#8b949e;font-weight:500;position:sticky;top:0}
|
||
tr:hover{background:#1c2128}
|
||
td.desc{color:#8b949e;font-size:11px;white-space:normal;max-width:260px}
|
||
td.wrap{white-space:normal;font-size:11px;max-width:200px}
|
||
.tree-node{padding:2px 0;font-size:13px}
|
||
.tree-children{padding-left:22px;border-left:1px solid #30363d;margin-left:8px}
|
||
.tree-node.ok{color:#3fb950}
|
||
.tree-node.warn{color:#d29922}
|
||
.tree-node.fail{color:#f85149}
|
||
.pipeline-ok{color:#3fb950}
|
||
.pipeline-warn{color:#d29922}
|
||
.pipeline-error{color:#f85149}
|
||
.pipeline-ever{color:#8b949e}
|
||
.orphan{background:#2d1517!important}
|
||
.orphan td{color:#f85149}
|
||
.summary{display:flex;gap:20px;margin-bottom:16px;font-size:13px;color:#8b949e;flex-wrap:wrap}
|
||
.summary span strong{color:#c9d1d9}
|
||
.toggle{color:#58a6ff;cursor:pointer;font-size:11px;margin-left:8px}
|
||
.search-box{background:#0d1117;border:1px solid #30363d;color:#c9d1d9;padding:6px 12px;border-radius:4px;margin-bottom:10px;width:300px;font-size:13px}
|
||
.cron-detail{font-size:11px;color:#8b949e;padding-left:20px}
|
||
.cron-tag{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;margin:0 2px}
|
||
.cron-tag.ok{background:#1a3a1a;color:#3fb950;border:1px solid #3fb950}
|
||
.cron-tag.warn{background:#3a2a1a;color:#d29922;border:1px solid #d29922}
|
||
.cron-tag.error{background:#3a1a1a;color:#f85149;border:1px solid #f85149}
|
||
.cron-tag.new{background:#1a1a3a;color:#58a6ff;border:1px solid #58a6ff}
|
||
/* 最后十次 报告按钮 & 弹窗 */
|
||
.pipe-reports-btn{background:#161b22;color:#58a6ff;border:1px solid #30363d;padding:2px 8px;border-radius:3px;font-size:11px;cursor:pointer;transition:all .15s}
|
||
.pipe-reports-btn:hover{background:#21262d;border-color:#58a6ff}
|
||
.rpt-modal-overlay{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:10000;display:flex;align-items:center;justify-content:center}
|
||
.rpt-modal{background:#161b22;border:1px solid #30363d;border-radius:8px;max-width:800px;width:90%;max-height:80vh;overflow-y:auto;padding:20px;position:relative}
|
||
.rpt-modal h3{color:#58a6ff;margin-bottom:12px;font-size:16px;display:flex;justify-content:space-between;align-items:center}
|
||
.rpt-modal .close-btn{cursor:pointer;color:#8b949e;font-size:20px;background:none;border:none}
|
||
.rpt-modal .close-btn:hover{color:#f85149}
|
||
.rpt-list{margin-bottom:12px}
|
||
.rpt-item{padding:8px 10px;border:1px solid #21262d;border-radius:4px;margin-bottom:4px;cursor:pointer;transition:all .15s}
|
||
.rpt-item:hover{background:#21262d;border-color:#30363d}
|
||
.rpt-item .rpt-title{font-size:13px;color:#c9d1d9;font-weight:500}
|
||
.rpt-item .rpt-meta{font-size:11px;color:#8b949e;margin-top:2px}
|
||
.rpt-detail{font-size:12px;color:#c9d1d9;white-space:pre-wrap;line-height:1.6;background:#0d1117;padding:12px;border-radius:4px;max-height:50vh;overflow-y:auto;border:1px solid #21262d}
|
||
.rpt-back{color:#58a6ff;cursor:pointer;font-size:12px;margin-bottom:8px;display:inline-block}
|
||
.rpt-back:hover{color:#79c0ff}
|
||
</style></head>
|
||
<body>
|
||
<h1>📊 MoFin 系统健康监控</h1>
|
||
<div class="summary" id="summary">加载中...</div>
|
||
<div class="tabs">
|
||
<div class="tab active" onclick="switchTab(0)">🌳 功能树</div>
|
||
<div class="tab" onclick="switchTab(1)">🔧 全部流程/Cron</div>
|
||
<div class="tab" onclick="switchTab(2)">🗃️ 数据实体</div>
|
||
<div class="tab" onclick="switchTab(3)">🔀 数据流</div>
|
||
<div class="tab" onclick="switchTab(4)">🩺 自检体系</div>
|
||
</div>
|
||
|
||
<div id="panel0" class="panel active"></div>
|
||
<div id="panel1" class="panel"></div>
|
||
<div id="panel2" class="panel"></div>
|
||
<div id="panel3" class="panel"></div>
|
||
<div id="panel4" class="panel"></div>
|
||
|
||
<script>
|
||
let data = null;
|
||
|
||
function renderSelfCheck(sc) {
|
||
if (!sc) return '<div style="color:#8b949e">自检体系数据未生成(等待 L1/L4 cron 首次运行)</div>';
|
||
let html = '';
|
||
const icon = s => s === 'ok' ? '✅' : s === 'warn' ? '🟡' : s === 'fail' ? '❌' : '⏭';
|
||
|
||
// 2026-08-12 三层架构健康(采集/加工/使用三层各自健康,老莫定)
|
||
if (sc.layers_health) {
|
||
const lh = sc.layers_health;
|
||
const tasks = lh.tasks || {};
|
||
const layerMeta = [
|
||
['collect', '📥 数据采集层', '#3fb950', lh.collect || {}],
|
||
['process', '⚙️ 数据加工层', '#d29922', lh.process || {}],
|
||
['use', '📊 数据使用层', '#58a6ff', lh.use || {}],
|
||
];
|
||
html += `<div style="background:#161b22;border:1px solid #58a6ff;border-radius:6px;padding:10px;margin-bottom:10px">`;
|
||
html += `<div style="font-weight:bold;color:#58a6ff;margin-bottom:6px">🏗️ 三层架构健康(采集→加工→使用)</div>`;
|
||
layerMeta.forEach(([layer, title, color, data]) => {
|
||
const t = tasks[layer] || {ok:0, error:0, total:0};
|
||
const tstatus = t.error > 0 ? '❌' : t.ok === t.total ? '✅' : '🟡';
|
||
html += `<div style="margin:6px 0;padding:8px;background:#0d1117;border-left:3px solid ${color};border-radius:4px">`;
|
||
html += `<strong style="color:${color}">${title}</strong> <span style="font-size:11px;color:#8b949e">任务 ${tstatus} ✅${t.ok} ❌${t.error} 共${t.total}</span>`;
|
||
html += '<table style="margin-top:4px"><thead><tr><th>数据</th><th>最新</th><th>覆盖/数量</th></tr></thead><tbody>';
|
||
Object.entries(data).forEach(([k, v]) => {
|
||
if (k === 'candidates' || k === 'active_strategies') return;
|
||
const latest = (v.latest || '无').toString().slice(0, 16);
|
||
const cnt = v.count !== undefined ? v.count : (typeof v === 'number' ? v : '-');
|
||
html += `<tr><td style="font-size:11px">${k}</td><td style="font-size:11px;color:#8b949e">${latest}</td><td style="font-size:11px">${cnt}</td></tr>`;
|
||
});
|
||
if (layer === 'use' && data.candidates) {
|
||
html += `<tr><td style="font-size:11px">candidates</td><td style="font-size:11px;color:#8b949e">${(data.candidates.latest||'').toString().slice(0,16)}</td><td style="font-size:11px">${data.candidates.count}(持仓策略${data.active_strategies||0})</td></tr>`;
|
||
}
|
||
html += '</tbody></table></div>';
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// L4 元监控(自检系统的自检)
|
||
if (sc.meta_watchdog) {
|
||
const mw = sc.meta_watchdog;
|
||
html += `<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin-bottom:10px">`;
|
||
html += `<div style="font-weight:bold;color:#58a6ff;margin-bottom:6px">🐕 L4 元监控 — 自检系统的自检 <span style="color:${mw.status === 'ok' ? '#3fb950' : '#f85149'}">${icon(mw.status)} ${mw.status}</span> <span style="color:#8b949e;font-size:11px">${mw.generated_at || ''}</span></div>`;
|
||
html += '<table><thead><tr><th>层</th><th>状态</th><th>说明</th></tr></thead>';
|
||
(mw.layers || []).forEach(l => {
|
||
html += `<tr><td>${l.layer}</td><td class="pipeline-${l.status === 'ok' ? 'ok' : 'error'}">${icon(l.status)} ${l.status}</td><td style="font-size:11px;color:#8b949e">${l.reason || ''}</td></tr>`;
|
||
});
|
||
html += '</table></div>';
|
||
}
|
||
|
||
// L1 功能健康
|
||
if (sc.functional) {
|
||
const fh = sc.functional;
|
||
const s = fh.summary || {};
|
||
html += `<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin-bottom:10px">`;
|
||
html += `<div style="font-weight:bold;color:#58a6ff;margin-bottom:6px">⚙️ L1 功能健康(功能是否达成,非进程存活) <span style="color:${fh.status === 'ok' ? '#3fb950' : fh.status === 'warn' ? '#d29922' : '#f85149'}">${icon(fh.status)} ${fh.status}</span> <span style="color:#8b949e;font-size:11px">ok:${s.ok} warn:${s.warn} fail:${s.fail} skip:${s.skip} · ${fh.generated_at || ''}</span></div>`;
|
||
html += '<table><thead><tr><th>模块</th><th>功能判据</th><th>状态</th><th>说明</th></tr></thead>';
|
||
(fh.checks || []).forEach(c => {
|
||
html += `<tr><td>${c.module}</td><td style="font-size:11px;color:#8b949e">${c.function}</td><td class="pipeline-${c.status === 'ok' ? 'ok' : c.status === 'warn' ? 'warn' : c.status === 'fail' ? 'error' : 'ever'}">${icon(c.status)} ${c.status}</td><td style="font-size:11px;color:#8b949e">${c.reason || ''}</td></tr>`;
|
||
});
|
||
html += '</table></div>';
|
||
}
|
||
|
||
// L2 卫生审计
|
||
if (sc.hygiene) {
|
||
const hy = sc.hygiene;
|
||
html += `<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin-bottom:10px">`;
|
||
html += `<div style="font-weight:bold;color:#58a6ff;margin-bottom:6px">🧹 L2 系统卫生审计(每日08:20) <span style="color:${hy.status === 'ok' ? '#3fb950' : '#d29922'}">${icon(hy.status)} ${hy.status}</span> <span style="color:#8b949e;font-size:11px">${hy.issue_count} 个问题 · ${hy.generated_at || ''}</span></div>`;
|
||
(hy.issues || []).forEach(i => {
|
||
html += `<div style="font-size:11px;color:#d29922">• [${i.type}] ${i.file || i.job || i.table || ''} → ${i.action || ''}</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// L3 修复记录
|
||
html += `<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin-bottom:10px">`;
|
||
html += `<div style="font-weight:bold;color:#58a6ff;margin-bottom:6px">🔧 L3 LLM 修复循环 — 最近修复动作(报备制)</div>`;
|
||
const reps = sc.recent_repairs || [];
|
||
if (!reps.length) {
|
||
html += '<div style="font-size:11px;color:#8b949e">暂无修复记录(系统健康或 L3 未触发)</div>';
|
||
} else {
|
||
reps.forEach(r => {
|
||
const okIcon = r.ok === true ? '✅' : r.ok === false ? '❌' : '⏭';
|
||
html += `<div style="font-size:11px;margin:3px 0"><span style="color:#8b949e">${(r.ts || '').slice(5, 16)}</span> ${okIcon} [${r.module}] ${(r.action || {}).action || r.action}: <span style="color:#8b949e">${(r.detail || r.reason || '').slice(0, 80)}</span></div>`;
|
||
});
|
||
}
|
||
html += '</div>';
|
||
return html;
|
||
}
|
||
|
||
function switchTab(idx) {
|
||
closeReportModal();
|
||
document.querySelectorAll('.tab').forEach((t,i)=>t.classList.toggle('active',i==idx));
|
||
document.querySelectorAll('.panel').forEach((p,i)=>p.classList.toggle('active',i==idx));
|
||
}
|
||
|
||
function renderFeatureTree(tree) {
|
||
let grp = (function(){let i=0;return function(){return 'ftg-'+(++i);}})();
|
||
let html = '<table><thead><tr><th style="width:32%">功能模块</th><th style="width:35%">说明</th><th>来源</th><th>类型</th><th>调度</th><th>状态</th><th>最后运行</th></tr></thead>';
|
||
|
||
function walk(node, depth, groupId) {
|
||
const indent = 'padding-left:'+(depth*20+8)+'px';
|
||
const cls = node.status || 'ok';
|
||
const hasKids = node.children && node.children.length > 0;
|
||
const pipes = node.pipes || [];
|
||
const isCat = hasKids && pipes.length === 0;
|
||
|
||
if (isCat) {
|
||
// 分类节点——生成一个可折叠组
|
||
const g = grp();
|
||
const desc = node.desc || '';
|
||
html += `<tr id="${g}-h" onclick="toggleFt('${g}')" style="cursor:pointer" data-group="${groupId||''}">`;
|
||
html += `<td style="${indent}"><span id="${g}-ico">▼</span> <span class="badge ${cls}"></span><strong>${node.label}</strong></td>`;
|
||
html += `<td style="font-size:11px;color:#8b949e">${desc}</td>`;
|
||
html += `<td>-</td><td>-</td><td>-</td><td class="pipeline-${cls}">${cls}</td><td>-</td></tr>`;
|
||
// 子节点都带上这个组的data-sub
|
||
node.children.forEach(c => walk(c, depth+1, g));
|
||
return;
|
||
}
|
||
|
||
if (pipes.length > 0) {
|
||
pipes.forEach((p, i) => {
|
||
const indent2 = i===0 ? indent : 'padding-left:'+(depth*20+32)+'px';
|
||
const tagCls = p.status==='ok'?'ok':p.status==='error'?'error':'warn';
|
||
const pBadge = p.profile==='position-analyst' ? '📋' : '📦';
|
||
const descText = i===0 ? (node.desc || '') : '';
|
||
const groupAttr = groupId ? `data-group="${groupId}"` : '';
|
||
if (i===0) {
|
||
html += `<tr ${groupAttr}><td style="${indent2}"><span class="badge ${cls}"></span><strong>${node.label}</strong></td>`;
|
||
} else {
|
||
html += `<tr ${groupAttr}><td style="${indent2}"><span style="color:#30363d">└</span> ${p.name||p.script||'LLM'}</td>`;
|
||
}
|
||
html += `<td style="font-size:11px;color:#8b949e">${descText}</td>`;
|
||
html += `<td><span class="cron-tag ${tagCls}">${p.type||'LLM'}</span></td>`;
|
||
html += `<td style="font-size:11px">${p.schedule||'-'}</td>`;
|
||
html += `<td class="pipeline-${tagCls}">${p.status}</td>`;
|
||
html += `<td style="font-size:11px">${p.last_run||'-'}</td></tr>`;
|
||
});
|
||
} else {
|
||
// 无cron的叶子
|
||
const groupAttr = groupId ? `data-group="${groupId}"` : '';
|
||
html += `<tr ${groupAttr}><td style="${indent}"><span class="badge ${cls}"></span><strong>${node.label}</strong></td>`;
|
||
html += `<td>-</td><td>-</td><td>-</td><td class="pipeline-${cls}">${cls}</td><td>-</td></tr>`;
|
||
}
|
||
|
||
if (hasKids && pipes.length > 0) {
|
||
node.children.forEach(c => walk(c, depth+1, groupId));
|
||
}
|
||
}
|
||
|
||
walk(tree, 0, null);
|
||
html += '</table>';
|
||
return html;
|
||
}
|
||
|
||
// 折叠切换:通过data-group控制
|
||
let ftState = {};
|
||
function toggleFt(g) {
|
||
const ico = document.getElementById(g+'-ico');
|
||
const isHidden = ftState[g];
|
||
ftState[g] = !isHidden;
|
||
ico.textContent = isHidden ? '▼' : '▶';
|
||
// 隐藏/显示所有带data-group={g}的行
|
||
document.querySelectorAll(`[data-group="${g}"]`).forEach(el => {
|
||
el.style.display = isHidden ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function renderDataFlow(entities, jsonFiles, architecture) {
|
||
// 只显示有写入方或读取方的实体
|
||
const filtered = entities.filter(e => (e.writers||[]).length > 0 || (e.readers||[]).length > 0);
|
||
// 只显示有读写关系的json
|
||
const jf = jsonFiles.filter(j => (j.readers||[]).length > 0);
|
||
|
||
let html = '<div style="margin-bottom:10px;color:#8b949e;font-size:13px">';
|
||
|
||
// 架构违规告警
|
||
const violations = (architecture||{}).price_api_violations || [];
|
||
if (violations.length > 0) {
|
||
const scripts = [...new Set(violations.map(v => v.script))];
|
||
html += `<div style="background:#3d1c02;border:1px solid #d29922;border-radius:6px;padding:10px;margin-bottom:12px">`;
|
||
html += `<div style="font-weight:bold;color:#d29922;margin-bottom:4px">⚠️ 架构违规: ${violations.length}处, ${scripts.length}个脚本自拉API不走live_prices</div>`;
|
||
html += `<div style="font-size:11px;color:#8b949e;margin-bottom:6px">预期: price_monitor→live_prices→其他脚本读。以下脚本直接调腾讯API:</div>`;
|
||
html += `<div style="font-size:11px">`;
|
||
scripts.forEach(s => {
|
||
const lines = violations.filter(v => v.script === s).map(v => `L${v.line}`).join(',');
|
||
html += `<span class="cron-tag" style="border-color:#d29922;color:#d29922;margin-right:4px;margin-bottom:3px;display:inline-block">${s} (${lines})</span>`;
|
||
});
|
||
html += `</div></div>`;
|
||
}
|
||
|
||
html += `显示 ${filtered.length} 个数据表 + ${jf.length} 个JSON文件的读写流向。绿色→写入,蓝色→读取。</div>`;
|
||
// 2026-08-19 多写方风险汇总
|
||
const risky = filtered.filter(e => (e.risk==='high'||(e.writers||[]).length>=3));
|
||
if (risky.length > 0) {
|
||
html += `<div style="background:#3d1c02;border:1px solid #f85149;border-radius:6px;padding:8px;margin:8px 0">`;
|
||
html += `<div style="font-weight:bold;color:#f85149;font-size:12px;margin-bottom:4px">🚨 篡改风险:${risky.length} 个数据实体有多写方竞争</div>`;
|
||
html += `<div style="font-size:11px;color:#d29922">多写方=多个模块写同一实体,可能互相覆写/篡改。重点:holding_strategies(策略参数)、candidates(候选池)、todos等。</div>`;
|
||
html += `</div>`;
|
||
}
|
||
|
||
// 按写入方数量排序,最活跃的排前面
|
||
filtered.sort((a,b) => (b.writers||[]).length - (a.writers||[]).length);
|
||
|
||
filtered.forEach(e => {
|
||
const w = e.writers || [];
|
||
const r = e.readers || [];
|
||
if (w.length === 0 && r.length === 0) return;
|
||
const fd = e.flow_detail || {};
|
||
const wDesc = fd.writers || {};
|
||
const rDesc = fd.readers || {};
|
||
|
||
html += '<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin-bottom:8px">';
|
||
// 2026-08-19 多写方风险徽标(老莫:数据篡改风险)
|
||
const wcnt = e.writer_count || (e.writers||[]).length;
|
||
const rcnt = e.reader_count || (e.readers||[]).length;
|
||
let riskBadge = '';
|
||
if (e.risk === 'high' || wcnt >= 3) {
|
||
riskBadge = `<span style="background:#3d1c02;color:#f85149;border:1px solid #f85149;border-radius:4px;padding:1px 6px;font-size:10px;margin-left:6px">⚠️多写方${wcnt}·篡改风险</span>`;
|
||
} else if (wcnt === 0) {
|
||
riskBadge = `<span style="color:#f85149;border:1px solid #f85149;border-radius:4px;padding:1px 6px;font-size:10px;margin-left:6px">☠️无写方·死实体</span>`;
|
||
} else if (wcnt === 2) {
|
||
riskBadge = `<span style="color:#d29922;border:1px solid #d29922;border-radius:4px;padding:1px 6px;font-size:10px;margin-left:6px">${wcnt}写方·关注</span>`;
|
||
}
|
||
html += `<div style="font-weight:bold;font-size:14px;color:#58a6ff;margin-bottom:4px">🗄️ ${e.name}${riskBadge} <span style="font-size:10px;color:#8b949e;font-weight:normal">(${wcnt}写/ ${rcnt}读)</span></div>`;
|
||
html += `<div style="font-size:11px;color:#8b949e;margin-bottom:4px">${e.desc||''}</div>`;
|
||
// 综合总结
|
||
if (fd.summary) {
|
||
html += `<div style="font-size:11px;color:#c9d1d9;margin-bottom:6px;padding:4px 8px;background:#0d1117;border-radius:4px">📌 ${fd.summary}</div>`;
|
||
}
|
||
|
||
// 写入方(带详细说明)
|
||
if (w.length > 0) {
|
||
html += '<div style="margin-bottom:4px">';
|
||
html += '<span style="color:#3fb950;font-size:11px;font-weight:bold">✏️ 写入:</span> ';
|
||
w.forEach(wn => {
|
||
const desc = wDesc[wn] || '';
|
||
html += `<div style="display:inline-block;margin:2px 4px 2px 0">`;
|
||
html += `<span class="cron-tag ok" style="font-size:11px">${wn}</span>`;
|
||
if (desc) html += `<span style="font-size:10px;color:#8b949e;margin-left:2px">— ${desc}</span>`;
|
||
html += `</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
// 读取方(带详细说明)
|
||
if (r.length > 0) {
|
||
html += '<div>';
|
||
html += '<span style="color:#58a6ff;font-size:11px;font-weight:bold">📖 读取:</span> ';
|
||
r.forEach(rn => {
|
||
const desc = rDesc[rn] || '';
|
||
html += `<div style="display:inline-block;margin:2px 4px 2px 0">`;
|
||
html += `<span class="cron-tag" style="border-color:#58a6ff;color:#58a6ff;font-size:11px">${rn}</span>`;
|
||
if (desc) html += `<span style="font-size:10px;color:#8b949e;margin-left:2px">— ${desc}</span>`;
|
||
html += `</div>`;
|
||
});
|
||
html += '</div>';
|
||
}
|
||
|
||
html += '</div>';
|
||
});
|
||
|
||
// JSON文件
|
||
jf.forEach(j => {
|
||
const r = j.readers || [];
|
||
html += '<div style="background:#161b22;border:1px solid #30363d;border-radius:6px;padding:10px;margin-bottom:8px">';
|
||
html += `<div style="font-weight:bold;font-size:14px;color:#d29922;margin-bottom:6px">📄 ${j.name}</div>`;
|
||
html += `<div style="font-size:11px;color:#8b949e;margin-bottom:6px">${j.desc||''} · ${j.size_kb}KB</div>`;
|
||
if (r.length > 0) {
|
||
html += '<div>';
|
||
html += '<span style="color:#58a6ff;font-size:11px">📖 读取:</span> ';
|
||
html += r.map(s => `<span class="cron-tag" style="border-color:#58a6ff;color:#58a6ff;font-size:11px">${s}</span>`).join(' ');
|
||
html += '</div>';
|
||
}
|
||
html += '</div>';
|
||
});
|
||
|
||
return html;
|
||
}
|
||
|
||
function renderPipelineTable(pipelines) {
|
||
// 2026-08-12 三层架构分组呈现(采集→加工→使用→监控,老莫定)
|
||
const LAYERS = [
|
||
['collect', '📥 数据采集层', '原始数据获取(K线/新闻/基本面/资金流/板块快照/宏观)'],
|
||
['process', '⚙️ 数据加工层', '衍生指标计算(技术指标/行业指数/多周期均线/分位)'],
|
||
['use', '📊 数据使用层', '策略扫描/评估/重评/候选管道(只读数据,不采集)'],
|
||
['monitor', '🩺 监控运维层', '健康检查/审计/备份/推送/自修复'],
|
||
];
|
||
const selStyle = 'background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:4px;padding:5px 8px;font-size:12px';
|
||
// 2026-08-12 系统 crontab 违规统计(老莫:显著提示)
|
||
const sysCnt = pipelines.filter(p => p.source === 'system_crontab').length;
|
||
let html = '';
|
||
// 显著横幅:开发原则——禁止系统 crontab 部署自动任务,统一 hermes
|
||
html += `<div style="background:#3d1f1f;border:1px solid #f85149;border-left:4px solid #f85149;border-radius:6px;padding:10px 14px;margin-bottom:12px">` +
|
||
`<div style="font-weight:bold;color:#f85149;font-size:14px">🚫 开发原则(强制):自动任务必须部署在 hermes(position-analyst),严禁使用系统 crontab</div>` +
|
||
`<div style="color:#d29922;font-size:12px;margin-top:4px">来源标记:✅ hermes(合规)| ⚠️ 系统cron(违规,需迁移)。当前检测到 <strong>${sysCnt}</strong> 个系统 crontab 部署的 MoFin 任务。</div>` +
|
||
(sysCnt > 0 ? `<div style="color:#f85149;font-size:12px;margin-top:2px">⚠️ 存在违规系统 cron,请迁移到 hermes 后从系统 crontab 移除(统一体系,避免新旧一起跑)。</div>` : `<div style="color:#3fb950;font-size:12px;margin-top:2px">✅ 无违规系统 cron,体系统一。</div>`) +
|
||
`</div>`;
|
||
html += '<div style="display:flex;gap:8px;margin-bottom:8px;align-items:center">';
|
||
html += '<input class="search-box" id="pipeSearch" placeholder="搜索流程..." oninput="filterPipes()" style="flex:1">';
|
||
html += `<select id="pipeLayer" onchange="filterPipes()" style="${selStyle}">`;
|
||
html += '<option value="all">全部层</option><option value="collect">📥 采集层</option><option value="process">⚙️ 加工层</option><option value="use">📊 使用层</option><option value="monitor">🩺 监控层</option></select>';
|
||
html += `<select id="pipeProfile" onchange="filterPipes()" style="${selStyle}">`;
|
||
html += '<option value="position-analyst">📋 知微</option><option value="all">📋+📦 全部</option></select></div>';
|
||
|
||
LAYERS.forEach(([layer, title, desc]) => {
|
||
const group = pipelines.filter(p => (p.layer || 'use') === layer);
|
||
if (!group.length) return;
|
||
const ok = group.filter(p => p.status === 'ok').length;
|
||
const err = group.filter(p => p.status === 'error' || p.status === 'fail').length;
|
||
const layerColor = {collect:'#3fb950', process:'#d29922', use:'#58a6ff', monitor:'#bc8cff'}[layer];
|
||
html += `<div style="margin:14px 0 6px;padding:8px 12px;background:#161b22;border-left:3px solid ${layerColor};border-radius:4px">`;
|
||
html += `<strong style="color:${layerColor}">${title}</strong> <span style="color:#8b949e;font-size:11px">${desc} · ${group.length}任务 ✅${ok} ❌${err}</span></div>`;
|
||
html += '<table><thead><tr><th>名称</th><th>来源</th><th>脚本/LLM</th><th>类型</th><th>调度</th><th>消息通道</th><th>状态</th><th>最后运行</th><th>最后十次</th></tr></thead><tbody>';
|
||
group.forEach((p) => {
|
||
const tagCls = p.status==='ok'?'ok':p.status==='error'?'error':'warn';
|
||
const statusDisplay = p.last_run ? p.status : '待首次运行';
|
||
const profileBadge = p.profile==='position-analyst' ? '📋' : '📦';
|
||
// 2026-08-12 cron来源标记:系统 crontab 违规用红色警告(老莫要求显著提示)
|
||
const isSys = p.source === 'system_crontab';
|
||
const srcBadge = isSys
|
||
? '<span style="color:#f85149;font-weight:bold">⚠️ 系统cron(违规)</span>'
|
||
: '<span style="color:#3fb950">✅ hermes</span>';
|
||
const rowStyle = isSys ? ' style="background:#2d1515;border-left:3px solid #f85149"' : '';
|
||
const encName = encodeURIComponent(p.name || '');
|
||
const encScript = encodeURIComponent(p.script || '');
|
||
html += `<tr class="pipe-row" data-profile="${p.profile||'default'}" data-layer="${p.layer||'use'}" data-name="${(p.name||'').toLowerCase()}"${rowStyle}><td>${p.name||p.script||'LLM'}</td>`;
|
||
html += `<td style="font-size:11px">${srcBadge} <span style="color:#8b949e">${profileBadge}${p.profile||'?'}</span></td>`;
|
||
html += `<td style="font-size:11px">${p.script||'LLM'}</td>`;
|
||
html += `<td><span class="cron-tag ${tagCls}">${p.type||'cron'}</span></td>`;
|
||
html += `<td style="font-size:11px">${p.schedule||'-'}</td>`;
|
||
const delColor = p.delivery==='xmpp'?'#3fb950':p.delivery==='both'?'#d29922':'#8b949e';
|
||
const delLabel = p.delivery==='xmpp'?'📣 xmpp':p.delivery==='both'?'🔄 both':'📡 broadcast';
|
||
html += `<td style="font-size:11px"><span style="color:${delColor};cursor:pointer;border:1px solid ${delColor};padding:1px 6px;border-radius:4px" onclick="cycDelivery('${encName}')" title="点击切换通道(broadcast/xmpp/both)">${delLabel}</span></td>`;
|
||
html += `<td class="pipeline-${tagCls}">${statusDisplay}</td>`;
|
||
html += `<td style="font-size:11px">${p.last_run||'-'}</td>`;
|
||
html += `<td><button class="pipe-reports-btn" data-cron="${encName}" data-script="${encScript}" data-name="${(p.name||'').replace(/"/g,'"')}" onclick="openLastReports(this)">最后十次</button></td></tr>`;
|
||
});
|
||
html += '</tbody></table>';
|
||
});
|
||
return html;
|
||
}
|
||
|
||
function filterPipes() {
|
||
const q = document.getElementById('pipeSearch').value.toLowerCase();
|
||
const profile = document.getElementById('pipeProfile').value;
|
||
const layer = (document.getElementById('pipeLayer')||{}).value || 'all';
|
||
document.querySelectorAll('.pipe-row').forEach(r => {
|
||
const name = r.getAttribute('data-name') || '';
|
||
const rp = r.getAttribute('data-profile') || 'default';
|
||
const rl = r.getAttribute('data-layer') || 'use';
|
||
const matchName = !q || name.includes(q);
|
||
const matchProfile = profile === 'all' || rp === profile;
|
||
const matchLayer = layer === 'all' || rl === layer;
|
||
r.style.display = matchName && matchProfile && matchLayer ? '' : 'none';
|
||
});
|
||
}
|
||
|
||
function loadData() {
|
||
fetch('/mofin_health.json?_='+Date.now())
|
||
.then(r=>r.json())
|
||
.then(d=>{
|
||
data = d;
|
||
// 统计
|
||
const cnt = {ok:0,warn:0,fail:0};
|
||
function countStatus(n) { cnt[n.status]++; if(n.children) n.children.forEach(countStatus); }
|
||
countStatus(d.feature_tree);
|
||
const pipeOk = d.pipelines.filter(p=>p.status==='ok').length;
|
||
const pipeErr = d.pipelines.filter(p=>p.status==='error'||p.status==='fail').length;
|
||
|
||
document.getElementById('summary').innerHTML =
|
||
`<span>生成: ${d.generated_at}</span>` +
|
||
`<span>功能: ✅${cnt.ok} ⚠️${cnt.warn} ❌${cnt.fail}</span>` +
|
||
`<span>Cron: ✅${pipeOk} ❌${pipeErr} 共${d.pipelines.length}</span>` +
|
||
`<span>数据表: ${d.entities.length} | ` +
|
||
`<span style="color:#f85149">孤立${d.entities.filter(e=>e.flow_status==='orphan').length}</span> ` +
|
||
`<span style="color:#d29922">写无读${d.entities.filter(e=>e.flow_status==='write_only').length}</span> ` +
|
||
`<span style="color:#58a6ff">读无写${d.entities.filter(e=>e.flow_status==='read_only').length}</span>` +
|
||
`<span style="color:#d29922;margin-left:8px">架构⚠️${(d.architecture||{}).violation_count||0}</span></span>`;
|
||
|
||
// Tab 0: 功能树
|
||
document.getElementById('panel0').innerHTML = renderFeatureTree(d.feature_tree);
|
||
|
||
// Tab 1: 全部流程表
|
||
document.getElementById('panel1').innerHTML = renderPipelineTable(d.pipelines);
|
||
|
||
// Tab 2: 数据实体(2026-08-12 三层架构分组:采集层原始/加工层衍生/使用层业务)
|
||
const ENT_LAYERS = [
|
||
['collect', '📥 采集层原始数据', '#3fb950'],
|
||
['process', '⚙️ 加工层衍生数据', '#d29922'],
|
||
['biz', '💼 使用层业务数据', '#58a6ff'],
|
||
];
|
||
const flowMap = {healthy:'✅ 正常', write_only:'✏️ 写无读', read_only:'📖 读无写', orphan:'🔴 孤立'};
|
||
const flowColors = {healthy:'#3fb950', write_only:'#d29922', read_only:'#58a6ff', orphan:'#f85149'};
|
||
let h2 = '';
|
||
ENT_LAYERS.forEach(([layer, title, lcolor]) => {
|
||
const group = d.entities.filter(e => (e.layer || 'biz') === layer);
|
||
if (!group.length) return;
|
||
const warn = group.filter(e => e.flow_status !== 'healthy').length;
|
||
h2 += `<div style="margin:14px 0 6px;padding:8px 12px;background:#161b22;border-left:3px solid ${lcolor};border-radius:4px">`;
|
||
h2 += `<strong style="color:${lcolor}">${title}</strong> <span style="color:#8b949e;font-size:11px">${group.length}表 ⚠️${warn}非健康</span></div>`;
|
||
h2 += '<table><thead><tr><th>表名</th><th>作用</th><th>行数</th><th>写入方</th><th>读取方</th><th>数据流</th></tr></thead><tbody>';
|
||
group.forEach(e => {
|
||
const badge = flowMap[e.flow_status] || '?';
|
||
const color = flowColors[e.flow_status] || '#8b949e';
|
||
const cls = e.orphan ? 'orphan' : '';
|
||
h2 += `<tr class="${cls}"><td><strong>${e.name}</strong></td><td class="desc">${e.desc||''}</td><td>${e.rows}</td>`;
|
||
h2 += `<td class="wrap">${(e.writers||[]).slice(0,4).join(', ')||'<span style="color:#f85149">无</span>'}</td>`;
|
||
h2 += `<td class="wrap">${(e.readers||[]).slice(0,4).join(', ')||'<span style="color:#f85149">无</span>'}</td>`;
|
||
h2 += `<td style="color:${color}">${badge}</td></tr>`;
|
||
});
|
||
h2 += '</tbody></table>';
|
||
});
|
||
// JSON 文件(归为兼容层)
|
||
if ((d.json_files||[]).length) {
|
||
h2 += `<div style="margin:14px 0 6px;padding:8px 12px;background:#161b22;border-left:3px solid #8b949e;border-radius:4px"><strong style="color:#8b949e">📄 JSON 兼容层</strong> <span style="color:#8b949e;font-size:11px">${d.json_files.length}个(多数已迁移DB)</span></div>`;
|
||
h2 += '<table><thead><tr><th>文件</th><th>作用</th><th>大小</th><th>写入</th><th>读取</th><th>状态</th></tr></thead><tbody>';
|
||
(d.json_files||[]).forEach(j => {
|
||
h2 += `<tr><td>📄 ${j.name}</td><td class="desc">${j.desc||'JSON文件'}</td><td>${j.size_kb}KB</td>`;
|
||
h2 += `<td class="wrap">JSON写入</td><td class="wrap">${(j.readers||[]).slice(0,4).join(', ')||'<span style="color:#f85149">无</span>'}</td>`;
|
||
h2 += `<td>${j.warn?'⚠️':'✅'}</td></tr>`;
|
||
});
|
||
h2 += '</tbody></table>';
|
||
}
|
||
document.getElementById('panel2').innerHTML = h2;
|
||
|
||
// Tab 3: 数据流
|
||
document.getElementById('panel3').innerHTML = renderDataFlow(d.entities, d.json_files||[], d.architecture||{});
|
||
|
||
// Tab 4: 自检体系
|
||
document.getElementById('panel4').innerHTML = renderSelfCheck(d.self_check);
|
||
})
|
||
.catch(e => document.getElementById('summary').innerHTML = '❌ 加载失败: ' + e.message);
|
||
}
|
||
|
||
loadData();
|
||
setInterval(loadData, 60000);
|
||
|
||
// ── 最后十次 报告 Modal ──
|
||
function closeReportModal() {
|
||
const el = document.getElementById('reportModal');
|
||
if (el) el.remove();
|
||
}
|
||
|
||
function openLastReports(btn) {
|
||
const cronKey = btn.getAttribute('data-cron');
|
||
const scriptKey = btn.getAttribute('data-script') || '';
|
||
const pipelineName = btn.getAttribute('data-name') || decodeURIComponent(cronKey);
|
||
// 创建/替换 modal
|
||
closeReportModal();
|
||
const overlay = document.createElement('div');
|
||
overlay.id = 'reportModal';
|
||
overlay.className = 'rpt-modal-overlay';
|
||
overlay.onclick = function(e){ if(e.target === this) closeReportModal(); };
|
||
overlay.innerHTML = `
|
||
<div class="rpt-modal">
|
||
<h3>📋 ${pipelineName} · 最后十次报告 <button class="close-btn" onclick="closeReportModal()">×</button></h3>
|
||
<div id="reportList" class="rpt-list">加载中...</div>
|
||
</div>`;
|
||
document.body.appendChild(overlay);
|
||
// 获取报告列表(cron=pipeline名 + script=脚本名,服务端多路匹配)
|
||
fetch('/api/reports?cron=' + cronKey + '&script=' + scriptKey + '&limit=10')
|
||
.then(r => r.json())
|
||
.then(reports => {
|
||
const list = document.getElementById('reportList');
|
||
if (!Array.isArray(reports) || reports.length === 0) {
|
||
list.innerHTML = '<div style="color:#8b949e;text-align:center;padding:20px">暂无报告记录</div>';
|
||
return;
|
||
}
|
||
list.innerHTML = reports.map(r => `
|
||
<div class="rpt-item" onclick="viewReportDetail('${r.id}')">
|
||
<div class="rpt-title">${r.title || r.id}</div>
|
||
<div class="rpt-meta">${r.type || '未知'} · ${r.created_at ? r.created_at.slice(0,16) : ''}${r.summary ? ' · ' + r.summary.slice(0,60) : ''}</div>
|
||
</div>`).join('');
|
||
})
|
||
.catch(e => {
|
||
document.getElementById('reportList').innerHTML = '<div style="color:#f85149">加载失败: ' + e.message + '</div>';
|
||
});
|
||
}
|
||
|
||
function viewReportDetail(reportId) {
|
||
const list = document.getElementById('reportList');
|
||
list.innerHTML = `
|
||
<span class="rpt-back" onclick="closeReportModal();">← 返回列表</span>
|
||
<div style="color:#8b949e;font-size:11px;margin-bottom:8px">正在加载报告: ${reportId}...</div>
|
||
`;
|
||
fetch('/api/report/' + reportId)
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (d.error) { list.innerHTML = '<div style="color:#f85149">报告不存在: ' + d.error + '</div>'; return; }
|
||
const title = d.title || reportId;
|
||
const type = d.type || '未知';
|
||
const created = d.created_at || '';
|
||
const content = d.content || d.summary || '';
|
||
list.innerHTML = `
|
||
<span class="rpt-back" onclick="closeReportModal();">← 返回列表</span>
|
||
<div style="margin-bottom:8px">
|
||
<strong style="color:#c9d1d9">${title}</strong>
|
||
<span style="color:#8b949e;margin-left:8px;font-size:11px">${type} · ${created}</span>
|
||
</div>
|
||
<div class="rpt-detail">${content.replace(/</g, '<')}</div>
|
||
`;
|
||
})
|
||
.catch(e => {
|
||
list.innerHTML = '<div style="color:#f85149">加载失败: ' + e.message + '</div>';
|
||
});
|
||
}
|
||
|
||
function cycDelivery(jobName) {
|
||
var uname = decodeURIComponent(jobName);
|
||
var row = document.querySelector('[data-name="'+uname.toLowerCase()+'"]');
|
||
if (!row) { alert('job not found'); return; }
|
||
var cur = row.querySelector('.pipe-row span') ? row.querySelector('.pipe-row span').textContent : '';
|
||
// 找到该行的delivery span
|
||
var spans = row.querySelectorAll('td:nth-child(6) span');
|
||
var curDel = spans[0] ? spans[0].textContent.replace(/[^ |