Files
winning-wallet-finder/live/serve_dashboard.py
T
gavindiaz 03748c17fb feat(dashboard): add system health, daily pipeline, wallet recommendations
3 new API endpoints:
- /api/health: 4 service statuses, proxy check, CASH≠CHAIN drift,
  heartbeat freshness (all green/red badges)
- /api/daily: daily.sh last run time, status, wallet pool counts
- /api/wallets: current followed wallets vs watch_sharps ranking,
  with eliminated/suggestion markers

3 new dashboard sections (60s refresh):
- 系统健康: paper/live/dashboard/proxy services + CASH≠CHAIN + heartbeat
- 每日流水线: last daily.sh run + skilled/sharps counts
- 钱包建议: followed wallets table with status (/) + suggested additions
2026-07-19 23:32:11 +08:00

273 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""极简 HTTP server:把 bot 的实时账本 JSON 暴露成可读的网页。
服务在 0.0.0.0:18080(端口通过 PORT 环境变量改)。
只依赖 Python 3 stdlib。
路由:
GET / -> 重定向到 /bot.html
GET /bot.html -> 中文实时仪表盘
GET /api/feed -> live/copybot_live.jsonpaper bot feed
GET /api/live -> live/copybot_live_real.jsonlive bot feed
GET /api/portfolio -> live/portfolio.json
GET /api/health -> 系统健康(服务状态 + 代理 + RTDS + CASH≠CHAIN
GET /api/daily -> daily.sh 最近运行状态
GET /api/wallets -> 钱包建议(当前跟的 vs watch_sharps 排名)
"""
import http.server
import json
import os
import socketserver
import subprocess
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE) # /opt/winning-wallet-finder
class Handler(http.server.SimpleHTTPRequestHandler):
"""默认行为是当文件服务器,但 /api/* 走自定义 JSON 接口。"""
def do_GET(self):
if self.path in ("/", "/bot.html"):
self.path = "/bot_dashboard.html"
elif self.path == "/api/feed":
self._serve_json("copybot_live.json", "paper feed")
return
elif self.path == "/api/live":
self._serve_json("copybot_live_real.json", "live feed")
return
elif self.path == "/api/portfolio":
self._serve_json("portfolio.json", "portfolio")
return
elif self.path == "/api/health":
self._serve_health()
return
elif self.path == "/api/daily":
self._serve_daily()
return
elif self.path == "/api/wallets":
self._serve_wallets()
return
# 让父类继续处理(serve 文件)
return super().do_GET()
def _serve_json(self, filename, kind):
path = os.path.join(HERE, filename)
if not os.path.exists(path):
self.send_error(404, f"{kind} not yet written ({filename} missing)")
return
try:
with open(path, "rb") as f:
data = f.read()
self._send_json_bytes(data)
except OSError as e:
self.send_error(500, f"read {filename} failed: {e}")
def _send_json_bytes(self, data):
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Content-Length", str(len(data)))
self.end_headers()
self.wfile.write(data)
def _send_json_obj(self, obj):
self._send_json_bytes(json.dumps(obj, ensure_ascii=False).encode())
def _serve_health(self):
"""系统健康状态:4 个服务 + 代理 + RTDS + CASH≠CHAIN + 心跳。"""
health = {"services": {}, "proxy": None, "rtds": None,
"cash_drift": None, "last_heartbeat": None, "ok": True}
# 1. 4 个 systemd 服务状态
for svc in ("copybot-paper", "copybot-live", "copybot-dashboard", "mihomo"):
try:
r = subprocess.run(["systemctl", "is-active", svc],
capture_output=True, text=True, timeout=3)
state = r.stdout.strip()
health["services"][svc] = state
if state != "active":
health["ok"] = False
except Exception:
health["services"][svc] = "unknown"
health["ok"] = False
# 2. 代理状态(跑 healthcheck 脚本,3 秒超时)
try:
r = subprocess.run(["/opt/mihomo-healthcheck.sh"],
capture_output=True, timeout=8)
health["proxy"] = "ok" if r.returncode == 0 else "fail"
if r.returncode != 0:
health["ok"] = False
except Exception:
health["proxy"] = "unknown"
# 3. 从 live feed 读 RTDS / CASH≠CHAIN / 心跳
feed_path = os.path.join(HERE, "copybot_live_real.json")
if os.path.exists(feed_path):
try:
feed = json.load(open(feed_path))
updated = feed.get("updated", 0)
health["last_heartbeat"] = updated
age = time.time() - updated if updated else 999
health["heartbeat_age_s"] = int(age)
if age > 300:
health["ok"] = False
drift = feed.get("ledger_drift", 0)
health["cash_drift"] = drift
if abs(drift) > 0.5:
health["ok"] = False
except Exception:
pass
# 也读 paper feed 的心跳
paper_path = os.path.join(HERE, "copybot_live.json")
if os.path.exists(paper_path):
try:
feed = json.load(open(paper_path))
health["paper_heartbeat"] = feed.get("updated", 0)
except Exception:
pass
self._send_json_obj(health)
def _serve_daily(self):
"""daily.sh 最近运行状态。"""
daily = {"last_run": None, "status": "unknown",
"skilled_count": 0, "sharps_count": 0}
# 读 daily.log 最后一行带时间戳的
log_path = os.path.join(HERE, "daily.log")
if os.path.exists(log_path):
try:
lines = open(log_path, encoding="utf-8", errors="replace").readlines()
for line in reversed(lines):
if "done ->" in line:
daily["status"] = "ok"
# 提取时间 "2026-07-19 02:36:56"
if "] " in line:
ts_part = line.split("] ")[1].split(" ")[0:2]
daily["last_run"] = " ".join(ts_part)
break
elif "[daily]" in line and "error" in line.lower():
daily["status"] = "error"
break
if daily["status"] == "unknown" and lines:
daily["last_run"] = "有日志但未完成"
except Exception:
pass
# 钱包池数量
for name, key in [("watch_skilled.json", "skilled_count"),
("watch_sharps.json", "sharps_count")]:
p = os.path.join(HERE, name)
if os.path.exists(p):
try:
data = json.load(open(p))
daily[key] = len(data) if isinstance(data, list) else 0
except Exception:
pass
self._send_json_obj(daily)
def _serve_wallets(self):
"""钱包建议:当前跟的 vs watch_sharps 排名。"""
result = {"followed": [], "suggestions": []}
# 1. 读当前跟的钱包(config.live.json
live_cfg_path = os.path.join(ROOT, "config.live.json")
followed = []
if os.path.exists(live_cfg_path):
try:
cfg = json.load(open(live_cfg_path))
followed = cfg.get("wallets", [])
except Exception:
pass
# 2. 读 watch_sharps.json 排名
sharps_path = os.path.join(HERE, "watch_sharps.json")
sharps = []
if os.path.exists(sharps_path):
try:
sharps = json.load(open(sharps_path))
except Exception:
pass
# 按 copy_pnl 排序
sharps_sorted = sorted(sharps, key=lambda w: w.get("copy_pnl", 0), reverse=True)
sharps_addrs = {w.get("wallet", "").lower(): i + 1 for i, w in enumerate(sharps_sorted)}
# 3. 标记当前跟的钱包
for w in followed:
addr = w.get("wallet", "").lower()
rank = sharps_addrs.get(addr)
name = w.get("name") or addr[:10]
if rank:
# 在 sharps 里
sharp_data = next((s for s in sharps_sorted
if s.get("wallet", "").lower() == addr), {})
result["followed"].append({
"name": name,
"rank": rank,
"copy_pnl": sharp_data.get("copy_pnl", 0),
"conv_win": sharp_data.get("conv_win"),
"status": "ok"
})
else:
# 不在 sharps 里 = 被淘汰
result["followed"].append({
"name": name,
"rank": None,
"copy_pnl": None,
"status": "eliminated"
})
# 4. 建议加的钱包(在 sharps 里但没跟,排名前 6)
followed_addrs = {w.get("wallet", "").lower() for w in followed}
for i, s in enumerate(sharps_sorted):
addr = s.get("wallet", "").lower()
if addr in followed_addrs:
continue
if i >= 6: # 只推荐前 6 名
break
name = s.get("name") or addr[:10]
result["suggestions"].append({
"name": name,
"rank": i + 1,
"copy_pnl": s.get("copy_pnl", 0),
"conv_win": s.get("conv_win"),
"wallet": addr
})
self._send_json_obj(result)
def end_headers(self):
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
super().end_headers()
def log_message(self, fmt, *args):
sys.stderr.write(f"[dashboard] {self.address_string()} - {fmt % args}\n")
class ReusableTCPServer(socketserver.TCPServer):
"""默认 TCPServer 关闭前不释放端口(TIME_WAIT),加 SO_REUSEADDR 让重启无缝。"""
allow_reuse_address = True
def main():
port = int(os.environ.get("PORT", "18080"))
os.chdir(HERE)
httpd = ReusableTCPServer(("0.0.0.0", port), Handler)
sys.stderr.write(f"[dashboard] serving {HERE} on 0.0.0.0:{port}\n")
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
httpd.server_close()
if __name__ == "__main__":
main()