chore: deployed L0-L4 self-check system

This commit is contained in:
知微
2026-07-20 19:40:23 +08:00
parent c545a82023
commit 135bfced5a
6 changed files with 2530 additions and 1758 deletions
@@ -0,0 +1,216 @@
#!/usr/bin/env python3
"""functional_health_check.py — L1 功能健康检查
判据不是"进程活着",而是"功能是否达成":每个核心模块检查其
**输出物的新鲜度和有效性**。输出物不新鲜 = 功能未达成 = 异常。
频率:交易时段每 15 分钟(cron),输出 gateway/logs/functional_health.json。
责任矩阵 L1。修复由 L3 self_repair 读取本报告执行。
"""
import os, sys, json, sqlite3, subprocess
from datetime import datetime, timedelta
sys.path.insert(0, '/home/hmo/MoFin')
DB = '/home/hmo/MoFin/data/mofin.db'
OUT = '/home/hmo/MoFin/gateway/logs/functional_health.json'
# ── 功能注册表:模块 → 功能判据 ──────────────────────────────
# type:
# db_freshness: DB 表 MAX(col) 距今不超过 max_agetrading=交易时段才检查, daily=每日, always=总是)
# file_freshness: 文件 mtime 距今不超过 max_age
# bot_activity: XMPP bot 活动检查(journal
# agent_log: gateway agent.log 最近有成功 LLM 调用
# soft=True: 无输出可能属正常(如区间未触发),降级为 warn 而非 fail
REGISTRY = [
{"module": "price_monitor", "function": "实时价格写入DB(live_prices)",
"check": {"type": "db_freshness", "table": "live_prices", "col": "updated_at",
"max_age_min": 6, "when": "trading"},
"repair": {"action": "rerun_script", "script": "price_monitor.py"}},
{"module": "market_watch", "function": "市场快照采集(market_snapshots)",
"check": {"type": "db_freshness", "table": "market_snapshots", "col": "created_at",
"max_age_min": 40, "when": "trading"},
"repair": {"action": "rerun_script", "script": "market_watch.py"}},
{"module": "mtf_cache", "function": "多周期均线缓存刷新(mtf_cache)",
"check": {"type": "db_freshness", "table": "mtf_cache", "col": "updated_at",
"max_age_min": 75, "when": "trading"},
"repair": {"action": "rerun_script", "script": "refresh_mtf_cache.py"}},
{"module": "macro_context", "function": "宏观上下文刷新(macro_context_log)",
"check": {"type": "db_freshness", "table": "macro_context_log", "col": "created_at",
"max_age_min": 45, "when": "trading"},
"repair": {"action": "rerun_script", "script": "refresh_macro_context.py"}},
{"module": "health_collector", "function": "健康数据采集(mofin_health.json)",
"check": {"type": "file_freshness", "path": "/home/hmo/web-dashboard/static/mofin_health.json",
"max_age_min": 20, "when": "trading"},
"repair": {"action": "rerun_script", "script": "mofin_health.py"}},
{"module": "premarket", "function": "盘前全量重评(premarket summary)",
"check": {"type": "file_freshness", "path": "/tmp/mofin_premarket/summary.json",
"max_age_h": 26, "when": "daily"},
"repair": {"action": "rerun_script", "script": "premarket_full_review.py"}},
{"module": "gateway_llm", "function": "LLM调用链可用(gateway agent.log)",
"check": {"type": "agent_log", "max_age_min": 30, "when": "always"},
"repair": {"action": "llm_diagnose"}},
{"module": "xmpp_bot", "function": "XMPP消息收发(bot journal)",
"check": {"type": "bot_activity", "when": "always"},
"repair": {"action": "llm_diagnose"}},
{"module": "cron_engine", "function": "cron调度引擎本身(有job在最近10min运行)",
"check": {"type": "cron_engine", "max_age_min": 12, "when": "trading"},
"repair": {"action": "llm_diagnose"}},
]
def is_trading_now(now):
return now.weekday() < 5 and 9 <= now.hour <= 16
def check_db_freshness(conn, chk, now):
try:
row = conn.execute(f"SELECT MAX({chk['col']}) FROM {chk['table']}").fetchone()
if not row or not row[0]:
return "fail", f"{chk['table']} 无数据"
last = datetime.fromisoformat(str(row[0]).replace("Z", ""))
age_min = (now - last).total_seconds() / 60
limit = chk.get("max_age_min", chk.get("max_age_h", 24) * 60)
if age_min > limit:
return ("warn" if chk.get("soft") else "fail"), \
f"{chk['table']} 最新记录 {last.strftime('%m-%d %H:%M')}{age_min/60:.1f}h前,阈值{limit}min"
return "ok", f"{age_min:.0f}min前"
except Exception as e:
return "fail", f"查询失败: {str(e)[:80]}"
def check_file_freshness(chk, now):
p = chk["path"]
if not os.path.exists(p):
return "fail", f"文件不存在: {p}"
age_min = (now.timestamp() - os.path.getmtime(p)) / 60
limit = chk.get("max_age_min", chk.get("max_age_h", 24) * 60)
if age_min > limit:
return ("warn" if chk.get("soft") else "fail"), \
f"{os.path.basename(p)} {age_min/60:.1f}h 未更新(阈值{limit}min"
return "ok", f"{age_min:.0f}min前"
def check_agent_log(chk, now):
try:
sys.path.insert(0, '/home/hmo/MoFin')
from xmpp_logger import _scan_agent_log
r = _scan_agent_log(now.timestamp(), "zhiwei")
if r["status"] == "ok":
age = r.get("age_sec", -1)
if age > chk["max_age_min"] * 60:
return "warn", f"最近成功LLM调用在 {age//60}min 前(无新流量,可能正常)"
return "ok", f"latency={r.get('latency')}"
return "fail", f"agent.log 最近调用失败: {r.get('error','?')[:100]}"
except Exception as e:
return "fail", f"检查失败: {e}"
def check_bot_activity(chk, now):
try:
# 第一判据:服务当前是否 active(权威"现在在不在跑"
r2 = subprocess.run(["systemctl", "is-active", "xmpp-zhiwei"],
capture_output=True, text=True, timeout=5)
if r2.stdout.strip() != "active":
return "fail", "bot 服务非 active(已停止)"
# 第二判据:最近 journal 是否处于断线循环
r = subprocess.run(
["journalctl", "-u", "xmpp-zhiwei", "--no-pager", "--since", "30 min ago", "-o", "cat"],
capture_output=True, timeout=8, text=True)
lines = r.stdout
if "连接超时" in lines and "就绪" not in lines:
return "fail", "bot 处于断线重连循环"
if "XMPP 就绪" in lines or "已发送" in lines or "收到" in lines:
return "ok", "bot 活动正常"
return "ok", "bot 在线空闲(无新消息)"
except Exception as e:
return "fail", f"检查失败: {e}"
def check_cron_engine(chk, now):
"""cron 引擎:最近 max_age_min 内是否有任何 job 运行过"""
try:
d = json.load(open('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json'))
jobs = d if isinstance(d, list) else d.get('jobs', [])
latest = None
for j in jobs:
lr = j.get('last_run_at')
if lr:
try:
t = datetime.fromisoformat(lr.replace('Z', '+00:00')).replace(tzinfo=None)
if latest is None or t > latest:
latest = t
except Exception:
pass
if not latest:
return "fail", "所有 job 均无运行记录"
age_min = (now - latest).total_seconds() / 60
if age_min > chk["max_age_min"]:
return "fail", f"最近 job 运行在 {age_min:.0f}min 前,调度引擎疑似停摆"
return "ok", f"最近 job 运行于 {age_min:.0f}min 前"
except Exception as e:
return "fail", f"检查失败: {e}"
def main():
now = datetime.now()
trading = is_trading_now(now)
results = []
conn = sqlite3.connect(DB, timeout=10)
for item in REGISTRY:
chk = item["check"]
when = chk.get("when", "always")
if when == "trading" and not trading:
results.append({"module": item["module"], "function": item["function"],
"status": "skip", "reason": "非交易时段"})
continue
if when == "daily":
# 每日类:只 fail 不 warn,且非交易日放宽到 72h
if now.weekday() >= 5:
chk = dict(chk)
chk["max_age_h"] = 72
t = chk["type"]
if t == "db_freshness":
status, reason = check_db_freshness(conn, chk, now)
elif t == "file_freshness":
status, reason = check_file_freshness(chk, now)
elif t == "agent_log":
status, reason = check_agent_log(chk, now)
elif t == "bot_activity":
status, reason = check_bot_activity(chk, now)
elif t == "cron_engine":
status, reason = check_cron_engine(chk, now)
else:
status, reason = "fail", f"未知检查类型 {t}"
results.append({"module": item["module"], "function": item["function"],
"status": status, "reason": reason,
"repair": item.get("repair")})
conn.close()
fails = [r for r in results if r["status"] == "fail"]
warns = [r for r in results if r["status"] == "warn"]
report = {
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"trading_hours": trading,
"summary": {"total": len(results), "ok": sum(1 for r in results if r["status"] == "ok"),
"warn": len(warns), "fail": len(fails),
"skip": sum(1 for r in results if r["status"] == "skip")},
"status": "fail" if fails else ("warn" if warns else "ok"),
"checks": results,
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"功能健康: {report['summary']} status={report['status']}")
for r in results:
if r["status"] in ("fail", "warn"):
print(f" {r['status'].upper()} {r['module']}: {r['reason']}")
if __name__ == "__main__":
main()
+123
View File
@@ -0,0 +1,123 @@
#!/usr/bin/env python3
"""meta_watchdog.py — L4 自检系统的自检(看门狗的看门狗)
检查 L0-L3 各自检组件本身是否在正常运转:
- L0 agents_health_check: last_health_check.json 是否 <10min
- L1 functional_health_check: functional_health.json 是否 <20min(交易时段)
- L2 system_hygiene_audit: hygiene_report.json 是否 <26h(每日)
- L3 self_repair: repair_state.json 存在性 + cron 是否注册
- mofin_health 采集: mofin_health.json 是否 <20min(交易时段)
- XMPP 桥: :5805 是否可发(self_repair 的报备通道)
任何一层死了 → 推 XMPP 点名(这是最后的兜底,必须直达用户)。
频率:每小时(cron)。输出 gateway/logs/meta_watchdog.json。
"""
import os, sys, json, subprocess
from datetime import datetime
OUT = '/home/hmo/MoFin/gateway/logs/meta_watchdog.json'
LAYERS = [
{"layer": "L0 agents_health_check", "file": "/home/hmo/MoFin/gateway/temp/last_health_check.json",
"max_age_min": 10, "when": "always",
"repair": "crontab */5 agents_health_check.py 停摆,检查系统 crontab"},
{"layer": "L1 functional_health", "file": "/home/hmo/MoFin/gateway/logs/functional_health.json",
"max_age_min": 25, "when": "trading",
"repair": "L1 cron 停摆,检查 hermes cron 引擎"},
{"layer": "L2 hygiene_audit", "file": "/home/hmo/MoFin/gateway/logs/hygiene_report.json",
"max_age_min": 26 * 60, "when": "always",
"repair": "L2 每日审计未跑,检查 hermes cron"},
{"layer": "L1.5 mofin_health采集", "file": "/home/hmo/web-dashboard/static/mofin_health.json",
"max_age_min": 25, "when": "trading",
"repair": "mofin_health.py 采集停摆"},
{"layer": "L3 self_repair", "file": "/home/hmo/MoFin/gateway/logs/repair_log.jsonl",
"max_age_min": None, "when": "meta",
"repair": "self_repair cron 未注册"},
]
def is_trading(now):
return now.weekday() < 5 and 9 <= now.hour <= 16
def main():
now = datetime.now()
trading = is_trading(now)
results = []
for L in LAYERS:
if L["when"] == "trading" and not trading:
results.append({"layer": L["layer"], "status": "skip", "reason": "非交易时段"})
continue
if L["when"] == "meta":
# 检查 self_repair 是否注册在 cron
try:
d = json.load(open('/home/hmo/.hermes/profiles/position-analyst/cron/jobs.json'))
jobs = d if isinstance(d, list) else d.get('jobs', [])
registered = any(j.get('script') == 'self_repair.py' and j.get('enabled', True) for j in jobs)
results.append({"layer": L["layer"],
"status": "ok" if registered else "fail",
"reason": "已注册" if registered else "未在 cron 注册"})
except Exception as e:
results.append({"layer": L["layer"], "status": "fail", "reason": str(e)[:60]})
continue
f = L["file"]
if not os.path.exists(f):
results.append({"layer": L["layer"], "status": "fail",
"reason": f"输出物不存在", "repair": L["repair"]})
continue
age_min = (now.timestamp() - os.path.getmtime(f)) / 60
if L["max_age_min"] and age_min > L["max_age_min"]:
results.append({"layer": L["layer"], "status": "fail",
"reason": f"输出物 {age_min/60:.1f}h 未更新(阈值 {L['max_age_min']}min",
"repair": L["repair"]})
else:
results.append({"layer": L["layer"], "status": "ok",
"reason": f"{age_min:.0f}min 前"})
# XMPP 桥(报备通道):只收 POSTGET 会 501,但任何 HTTP 响应都说明进程活着
try:
import urllib.request
urllib.request.urlopen('http://127.0.0.1:5805/', timeout=3)
results.append({"layer": "XMPP桥 :5805", "status": "ok", "reason": "可达"})
except urllib.error.HTTPError as e:
results.append({"layer": "XMPP桥 :5805", "status": "ok", "reason": f"可达(HTTP {e.code})"})
except Exception:
results.append({"layer": "XMPP桥 :5805", "status": "fail",
"reason": "不可达", "repair": "重启 xmpp-zhiwei"})
fails = [r for r in results if r["status"] == "fail"]
report = {
"generated_at": now.strftime("%Y-%m-%d %H:%M:%S"),
"status": "fail" if fails else "ok",
"layers": results,
}
os.makedirs(os.path.dirname(OUT), exist_ok=True)
with open(OUT, 'w', encoding='utf-8') as f:
json.dump(report, f, ensure_ascii=False, indent=2)
print(f"meta_watchdog: {report['status']}")
for r in results:
icon = {"ok": "", "fail": "", "skip": ""}[r["status"]]
print(f" {icon} {r['layer']}: {r['reason']}")
if fails:
try:
import urllib.request
lines = [f"🚨 自检系统自检(L4兜底)发现 {len(fails)} 层异常:"]
for r in fails:
lines.append(f"{r['layer']}: {r['reason']}")
if r.get('repair'):
lines.append(f" → 处置建议: {r['repair']}")
payload = json.dumps({'to': 'hmo@yoin.fun', 'body': '\n'.join(lines), 'type': 'chat'}).encode()
req = urllib.request.Request('http://127.0.0.1:5805/', data=payload,
headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=5)
print(' 📨 已推 XMPP(兜底直达)')
except Exception as e:
print(f' XMPP 失败: {e}')
if __name__ == '__main__':
main()
File diff suppressed because it is too large Load Diff
+255
View File
@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""self_repair.py — L3 LLM 修复循环(报备制)
读取 L1 functional_health.json + L2 hygiene_report.json 的失败项,
调用 LLM 诊断并**直接执行修复**(先斩后奏),然后记录日志并 XMPP 报备。
安全设计:
- LLM 只能从白名单动作中选择(不许任意执行代码)
- 每个模块每天最多自动修复 2 次(防修复循环)
- LLM 不可用时降级为规则默认动作(rerun_script
- 所有动作记录 repair_log.jsonl
频率:每 30 分钟(cron)。
"""
import os, sys, json, subprocess, sqlite3, time
from datetime import datetime
sys.path.insert(0, '/home/hmo/MoFin')
FUNCTIONAL_REPORT = '/home/hmo/MoFin/gateway/logs/functional_health.json'
HYGIENE_REPORT = '/home/hmo/MoFin/gateway/logs/hygiene_report.json'
REPAIR_LOG = '/home/hmo/MoFin/gateway/logs/repair_log.jsonl'
REPAIR_STATE = '/home/hmo/MoFin/gateway/logs/repair_state.json'
GATEWAY = 'http://127.0.0.1:8643/v1/chat/completions'
SCRIPTS_DIR = '/home/hmo/.hermes/profiles/position-analyst/scripts'
MAX_REPAIRS_PER_MODULE_PER_DAY = 2
WHITELIST_ACTIONS = """
可执行的白名单动作(只能选其一,不许自创):
1. {"action": "rerun_script", "script": "<脚本名>"} — 立即重跑指定 cron 脚本(限注册表内的脚本)
2. {"action": "restart_service", "service": "<服务名>"} — systemctl 重启(限: hermes-gateway-zhiwei, xmpp-zhiwei, mofin-dashboard
3. {"action": "sync_links"} — 重建脚本硬链接(修复 cron 跑旧代码)
4. {"action": "switch_llm_key"} — 切换 LLM key429 限流时)
5. {"action": "none", "reason": "<原因>"} — 判断为无需动作(如非交易时段的正常空闲)
"""
def log_repair(entry):
os.makedirs(os.path.dirname(REPAIR_LOG), exist_ok=True)
with open(REPAIR_LOG, 'a', encoding='utf-8') as f:
f.write(json.dumps(entry, ensure_ascii=False) + '\n')
def load_state():
try:
return json.load(open(REPAIR_STATE))
except Exception:
return {}
def save_state(s):
with open(REPAIR_STATE, 'w') as f:
json.dump(s, f)
def repairs_today(state, module):
today = datetime.now().strftime('%Y-%m-%d')
return state.get(today, {}).get(module, 0)
def bump_repair(state, module):
today = datetime.now().strftime('%Y-%m-%d')
state.setdefault(today, {})
state[today][module] = state[today].get(module, 0) + 1
# 只保留最近 3 天
for k in list(state.keys()):
if k < (datetime.now().strftime('%Y-%m-%d'))[:8] + '17':
del state[k]
save_state(state)
def collect_failures():
failures = []
try:
fh = json.load(open(FUNCTIONAL_REPORT))
for c in fh.get('checks', []):
if c.get('status') == 'fail':
failures.append({
'source': 'functional', 'module': c['module'],
'function': c['function'], 'reason': c['reason'],
'repair_hint': c.get('repair'),
})
except Exception:
pass
try:
hy = json.load(open(HYGIENE_REPORT))
for i in hy.get('issues', []):
failures.append({
'source': 'hygiene', 'module': i.get('type'),
'function': i.get('file') or i.get('job') or i.get('table', '?'),
'reason': json.dumps(i, ensure_ascii=False)[:200],
'repair_hint': {'action': 'llm_diagnose'},
})
except Exception:
pass
return failures
def run_action(action, module):
"""执行白名单动作,返回 (ok, detail)"""
act = action.get('action')
try:
if act == 'rerun_script':
script = action.get('script', '')
# 只允许注册表内脚本
if not script.endswith('.py') or '/' in script or '..' in script:
return False, f'非法脚本名: {script}'
path = os.path.join(SCRIPTS_DIR, script)
if not os.path.exists(path):
return False, f'脚本不存在: {script}'
r = subprocess.run(['python3', path], capture_output=True, text=True,
timeout=600, cwd=SCRIPTS_DIR)
tail = (r.stdout or r.stderr)[-150:]
return r.returncode == 0, f'exit={r.returncode} {tail}'
elif act == 'restart_service':
svc = action.get('service', '')
allowed = {'hermes-gateway-zhiwei': ['sudo', '-n', 'systemctl', 'restart', svc],
'xmpp-zhiwei': ['sudo', '-n', 'systemctl', 'restart', svc],
'mofin-dashboard': ['sudo', '-n', 'systemctl', 'restart', svc]}
if svc not in allowed:
return False, f'服务不在白名单: {svc}'
subprocess.Popen(allowed[svc], stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL, start_new_session=True)
return True, f'{svc} 重启已触发(异步)'
elif act == 'sync_links':
r = subprocess.run(['bash', '/home/hmo/MoFin/deploy/profile-scripts/sync_profile_scripts.sh'],
capture_output=True, text=True, timeout=60)
return True, r.stdout.strip()
elif act == 'switch_llm_key':
sys.path.insert(0, '/home/hmo/MoFin')
from xmpp_logger import best_key, switch_key, KEY_TO_PROVIDER, current_provider
bk = best_key()
if not bk:
return False, '无可用 key'
target = KEY_TO_PROVIDER.get(bk['key_id'])
if not target or target == current_provider():
return False, f'已在最优 key ({bk["key_id"]})'
r = switch_key(bk['key_id'])
return r.get('switched', False), json.dumps(r, ensure_ascii=False)[:150]
elif act == 'none':
return True, f'无需动作: {action.get("reason", "")}'
return False, f'未知动作: {act}'
except subprocess.TimeoutExpired:
return False, '动作超时(600s)'
except Exception as e:
return False, f'动作异常: {str(e)[:120]}'
def llm_diagnose(failure):
"""调 LLM 诊断并返回白名单动作。LLM 不可用 → 返回 None(走规则默认)"""
hint = failure.get('repair_hint') or {}
default_action = hint if hint.get('action') and hint['action'] != 'llm_diagnose' else None
prompt = f"""你是 MoFin 系统的自动修复工程师。一个健康检查发现了功能异常,请诊断并给出一个修复动作。
【异常信息】
模块: {failure['module']}
功能: {failure['function']}
异常表现: {failure['reason']}
来源: {failure['source']}
【背景】
- 系统: Linux 246, MoFin 股票分析系统
- cron 脚本目录: {SCRIPTS_DIR}
- 数据库: /home/hmo/MoFin/data/mofin.db
- 当前时间: {datetime.now().strftime('%Y-%m-%d %H:%M')}(注意是否交易时段,非交易时段部分管道停跑属正常)
{WHITELIST_ACTIONS}
请只输出一个 JSON 动作(不要其他文字)。如果是非交易时段的正常空闲,选 none。"""
try:
payload = json.dumps({
'model': 'deepseek-v4-flash',
'messages': [{'role': 'user', 'content': prompt}],
'max_tokens': 300, 'stream': False,
}).encode()
import urllib.request
req = urllib.request.Request(GATEWAY, data=payload, headers={
'Content-Type': 'application/json', 'Authorization': 'Bearer hermes123',
'X-Hermes-Session-Id': 'self-repair'})
resp = urllib.request.urlopen(req, timeout=120)
text = json.loads(resp.read())['choices'][0]['message']['content']
# 提取 JSON
import re
m = re.search(r'\{[^{}]*"action"[^{}]*\}', text)
if m:
action = json.loads(m.group(0))
if action.get('action') in ('rerun_script', 'restart_service', 'sync_links',
'switch_llm_key', 'none'):
return action, text[:200]
except Exception as e:
print(f' LLM 诊断不可用: {str(e)[:80]}')
return (default_action, None) if default_action else (None, None)
def xmpp_report(lines):
try:
import urllib.request
payload = json.dumps({'to': 'hmo@yoin.fun', 'body': '\n'.join(lines), 'type': 'chat'}).encode()
req = urllib.request.Request('http://127.0.0.1:5805/', data=payload,
headers={'Content-Type': 'application/json'})
urllib.request.urlopen(req, timeout=5)
except Exception as e:
print(f'XMPP 失败: {e}')
def main():
now = datetime.now()
print('🔧 self_repair', now.strftime('%H:%M'))
failures = collect_failures()
if not failures:
print(' ✅ 无失败项')
return
state = load_state()
reports = []
for f in failures:
module = f['module']
if repairs_today(state, module) >= MAX_REPAIRS_PER_MODULE_PER_DAY:
print(f'{module}: 今日已修复 {MAX_REPAIRS_PER_MODULE_PER_DAY} 次,跳过(防循环)')
continue
print(f' 🔍 {module}: {f["reason"][:60]}')
action, llm_note = llm_diagnose(f)
if action is None:
print(f' 无可用动作,跳过')
log_repair({'ts': now.isoformat(), 'module': module, 'reason': f['reason'],
'action': 'skip_no_action', 'ok': None})
continue
ok, detail = run_action(action, module)
bump_repair(state, module)
entry = {
'ts': now.isoformat(), 'module': module, 'function': f['function'],
'reason': f['reason'], 'action': action, 'ok': ok, 'detail': detail[:300],
'llm_note': llm_note,
}
log_repair(entry)
icon = '' if ok else ''
print(f' {icon} {action.get("action")} -> {detail[:80]}')
reports.append(f'{icon} [{module}] {action.get("action")}: {detail[:80]}')
if reports:
xmpp_report(['🔧 自检系统自动修复报备:', f'发现 {len(failures)} 个功能异常,已直接处理:'] + reports +
['', '详情: gateway/logs/repair_log.jsonl'])
if __name__ == '__main__':
main()
+390 -328
View File
@@ -1,328 +1,390 @@
<!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}
</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>
<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>
<script>
let data = null;
function switchTab(idx) {
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>`;
// 按写入方数量排序,最活跃的排前面
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">';
html += `<div style="font-weight:bold;font-size:14px;color:#58a6ff;margin-bottom:4px">🗄️ ${e.name}</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) {
let 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="pipeProfile" onchange="filterPipes()" style="background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:4px;padding:5px 8px;font-size:12px">';
html += '<option value="position-analyst">📋 知微</option>';
html += '<option value="all">📋+📦 全部</option>';
html += '</select></div>';
html += '<table><thead><tr><th>名称</th><th>来源</th><th>脚本/LLM</th><th>类型</th><th>调度</th><th>状态</th><th>最后运行</th></tr></thead><tbody id="pipeBody">';
pipelines.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' ? '📋' : '📦';
html += `<tr class="pipe-row" data-profile="${p.profile||'default'}" data-name="${(p.name||'').toLowerCase()}"><td>${p.name||p.script||'LLM'}</td>`;
html += `<td style="font-size:11px">${profileBadge} ${p.profile||'?'}</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>`;
html += `<td class="pipeline-${tagCls}">${statusDisplay}</td>`;
html += `<td style="font-size:11px">${p.last_run||'-'}</td></tr>`;
});
html += '</tbody></table>';
return html;
}
function filterPipes() {
const q = document.getElementById('pipeSearch').value.toLowerCase();
const profile = document.getElementById('pipeProfile').value;
document.querySelectorAll('.pipe-row').forEach(r => {
const name = r.getAttribute('data-name') || '';
const rp = r.getAttribute('data-profile') || 'default';
const matchName = !q || name.includes(q);
const matchProfile = profile === 'all' || rp === profile;
r.style.display = matchName && matchProfile ? '' : '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: 数据实体
let h2 = '<table><thead><tr><th>表名</th><th>作用</th><th>行数</th><th>写入方</th><th>读取方</th><th>数据流</th></tr></thead><tbody>';
d.entities.forEach(e => {
const flowMap = {healthy:'✅ 正常', write_only:'✏️ 写无读', read_only:'📖 读无写', orphan:'🔴 孤立'};
const flowColors = {healthy:'#3fb950', write_only:'#d29922', read_only:'#58a6ff', orphan:'#f85149'};
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>`;
});
(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||{});
})
.catch(e => document.getElementById('summary').innerHTML = '❌ 加载失败: ' + e.message);
}
loadData();
setInterval(loadData, 60000);
</script>
</body></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}
</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' ? '❌' : '⏭';
// 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) {
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>`;
// 按写入方数量排序,最活跃的排前面
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">';
html += `<div style="font-weight:bold;font-size:14px;color:#58a6ff;margin-bottom:4px">🗄️ ${e.name}</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) {
let 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="pipeProfile" onchange="filterPipes()" style="background:#21262d;color:#c9d1d9;border:1px solid #30363d;border-radius:4px;padding:5px 8px;font-size:12px">';
html += '<option value="position-analyst">📋 知微</option>';
html += '<option value="all">📋+📦 全部</option>';
html += '</select></div>';
html += '<table><thead><tr><th>名称</th><th>来源</th><th>脚本/LLM</th><th>类型</th><th>调度</th><th>状态</th><th>最后运行</th></tr></thead><tbody id="pipeBody">';
pipelines.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' ? '📋' : '📦';
html += `<tr class="pipe-row" data-profile="${p.profile||'default'}" data-name="${(p.name||'').toLowerCase()}"><td>${p.name||p.script||'LLM'}</td>`;
html += `<td style="font-size:11px">${profileBadge} ${p.profile||'?'}</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>`;
html += `<td class="pipeline-${tagCls}">${statusDisplay}</td>`;
html += `<td style="font-size:11px">${p.last_run||'-'}</td></tr>`;
});
html += '</tbody></table>';
return html;
}
function filterPipes() {
const q = document.getElementById('pipeSearch').value.toLowerCase();
const profile = document.getElementById('pipeProfile').value;
document.querySelectorAll('.pipe-row').forEach(r => {
const name = r.getAttribute('data-name') || '';
const rp = r.getAttribute('data-profile') || 'default';
const matchName = !q || name.includes(q);
const matchProfile = profile === 'all' || rp === profile;
r.style.display = matchName && matchProfile ? '' : '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: 数据实体
let h2 = '<table><thead><tr><th>表名</th><th>作用</th><th>行数</th><th>写入方</th><th>读取方</th><th>数据流</th></tr></thead><tbody>';
d.entities.forEach(e => {
const flowMap = {healthy:'✅ 正常', write_only:'✏️ 写无读', read_only:'📖 读无写', orphan:'🔴 孤立'};
const flowColors = {healthy:'#3fb950', write_only:'#d29922', read_only:'#58a6ff', orphan:'#f85149'};
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>`;
});
(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);
</script>
</body></html>
+528 -454
View File
File diff suppressed because it is too large Load Diff