chore: deployed L0-L4 self-check system
This commit is contained in:
@@ -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_age(trading=交易时段才检查, 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()
|
||||
@@ -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 桥(报备通道):只收 POST,GET 会 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()
|
||||
+1018
-976
File diff suppressed because it is too large
Load Diff
@@ -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 key(429 限流时)
|
||||
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
@@ -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
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user