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
This commit is contained in:
2026-07-19 23:32:11 +08:00
parent a91b243eeb
commit 03748c17fb
2 changed files with 343 additions and 19 deletions
+146
View File
@@ -58,6 +58,26 @@ footer{text-align:center;color:var(--dim);font-size:12px;margin-top:30px}
· <button id="toggle-mode" onclick="toggleMode()" style="background:var(--line);color:var(--fg);border:1px solid var(--line);border-radius:4px;padding:3px 12px;cursor:pointer;font-size:12px;">PAPER</button>
</div>
<!-- 系统健康面板 -->
<div class="section" id="health-section">
<h2>系统健康</h2>
<div id="health-content" style="display:flex;flex-wrap:wrap;gap:8px;font-size:13px;">
<span style="color:var(--dim)">加载中…</span>
</div>
</div>
<!-- 每日流水线面板 -->
<div class="section" id="daily-section">
<h2>每日流水线</h2>
<div id="daily-content" style="font-size:13px;color:var(--dim)">加载中…</div>
</div>
<!-- 钱包建议面板 -->
<div class="section" id="wallets-section">
<h2>钱包建议</h2>
<div id="wallets-content" style="font-size:13px;color:var(--dim)">加载中…</div>
</div>
<div class="cards">
<div class="card"><div class="lbl">现金</div><div class="val" id="m-cash"></div></div>
<div class="card"><div class="lbl">已部署</div><div class="val" id="m-deployed"></div></div>
@@ -249,6 +269,132 @@ async function render() {
render();
setInterval(render, 30000);
// ========== 系统健康面板 ==========
async function renderHealth() {
let h;
try {
const r = await fetch('/api/health', {cache: 'no-store'});
h = await r.json();
} catch (e) {
document.getElementById('health-content').innerHTML =
'<span class="error">获取失败: ' + e.message + '</span>';
return;
}
const svcNames = {
'copybot-paper': 'Paper Bot',
'copybot-live': 'Live Bot',
'copybot-dashboard': 'Dashboard',
'mihomo': '代理'
};
let html = '';
for (const [svc, label] of Object.entries(svcNames)) {
const state = h.services?.[svc] || 'unknown';
const color = state === 'active' ? 'var(--green)' : 'var(--red)';
html += `<span class="wallet-badge" style="background:${color === 'var(--green)' ? '#1a3322' : '#3a1a1a'};color:${color}">${label}: ${state}</span>`;
}
// 代理
const proxyOk = h.proxy === 'ok';
html += `<span class="wallet-badge" style="background:${proxyOk ? '#1a3322' : '#3a1a1a'};color:${proxyOk ? 'var(--green)' : 'var(--red)'}">代理: ${h.proxy || '?'}</span>`;
// CASH≠CHAIN
if (h.cash_drift !== null && h.cash_drift !== undefined) {
const driftOk = Math.abs(h.cash_drift) < 0.5;
html += `<span class="wallet-badge" style="background:${driftOk ? '#1a3322' : '#3a1a1a'};color:${driftOk ? 'var(--green)' : 'var(--red)'}">CASH≠CHAIN: ${driftOk ? '正常' : '$' + h.cash_drift}</span>`;
}
// 心跳
if (h.last_heartbeat) {
const ageMin = Math.floor((h.heartbeat_age_s || 0) / 60);
const ageSec = (h.heartbeat_age_s || 0) % 60;
const fresh = (h.heartbeat_age_s || 999) < 300;
html += `<span class="wallet-badge" style="background:${fresh ? '#1a3322' : '#3a1a1a'};color:${fresh ? 'var(--green)' : 'var(--red)'}">心跳: ${ageMin}${ageSec}秒前</span>`;
}
document.getElementById('health-content').innerHTML = html;
}
// ========== 每日流水线面板 ==========
async function renderDaily() {
let d;
try {
const r = await fetch('/api/daily', {cache: 'no-store'});
d = await r.json();
} catch (e) {
document.getElementById('daily-content').innerHTML =
'<span class="error">获取失败</span>';
return;
}
const ok = d.status === 'ok';
const color = ok ? 'var(--green)' : 'var(--red)';
let html = `<span class="wallet-badge" style="background:${ok ? '#1a3322' : '#3a1a1a'};color:${color}">${d.status === 'ok' ? '✓ 正常' : d.status}</span>`;
if (d.last_run) {
html += ` <span style="color:var(--dim)">上次运行: ${d.last_run}</span>`;
}
html += ` <span style="color:var(--dim)">· 钱包池: ${d.skilled_count} skilled / ${d.sharps_count} sharps</span>`;
document.getElementById('daily-content').innerHTML = html;
}
// ========== 钱包建议面板 ==========
async function renderWallets() {
let w;
try {
const r = await fetch('/api/wallets', {cache: 'no-store'});
w = await r.json();
} catch (e) {
document.getElementById('wallets-content').innerHTML =
'<span class="error">获取失败</span>';
return;
}
let html = '<table style="width:100%"><thead><tr>' +
'<th>当前跟的</th><th>排名</th><th>copy_pnl</th><th>信念胜率</th><th>状态</th></tr></thead><tbody>';
for (const f of (w.followed || [])) {
let statusBadge, statusColor;
if (f.status === 'ok') {
statusBadge = '✅ 正常';
statusColor = 'var(--green)';
} else {
statusBadge = '❌ 已淘汰';
statusColor = 'var(--red)';
}
html += `<tr>
<td>${f.name}</td>
<td>${f.rank ? '#' + f.rank : '-'}</td>
<td>${f.copy_pnl != null ? '+' + f.copy_pnl : '-'}</td>
<td>${f.conv_win != null ? f.conv_win + '%' : '-'}</td>
<td style="color:${statusColor}">${statusBadge}</td>
</tr>`;
}
html += '</tbody></table>';
// 建议加的
const suggestions = w.suggestions || [];
if (suggestions.length > 0) {
html += '<div style="margin-top:10px;color:var(--dim);font-size:12px">建议加(watch_sharps 排名前 6 未跟的):</div>';
html += '<div style="margin-top:4px">';
for (const s of suggestions) {
html += `<span class="wallet-badge" style="background:#1a2438;color:var(--blue)">⬆️ ${s.name} · #${s.rank} · +${s.copy_pnl}${s.conv_win != null ? ' · ' + s.conv_win + '%' : ''}</span>`;
}
html += '</div>';
} else {
html += '<div style="margin-top:10px;color:var(--green);font-size:12px">当前跟的钱包都在 watch_sharps 里,无需调整 ✓</div>';
}
document.getElementById('wallets-content').innerHTML = html;
}
// 健康面板 60 秒刷新(比交易数据慢,系统状态变化少)
renderHealth();
renderDaily();
renderWallets();
setInterval(renderHealth, 60000);
setInterval(renderDaily, 60000);
setInterval(renderWallets, 60000);
</script>
</body>
</html>
+197 -19
View File
@@ -7,24 +7,23 @@
路由:
GET / -> 重定向到 /bot.html
GET /bot.html -> 中文实时仪表盘
GET /api/feed -> live/copybot_live.jsonbot 写入的
GET /api/portfolio -> live/portfolio.jsonportfolio.py 算出的
为什么不直接用 live/dashboard.html 那个:
那个是从 watch_skilled.json 生成的"钱包池"71 个跟单对象),
这个是 bot 的"实时账本"(现金/持仓/盈亏)。两件事。
用法:
python3 serve_dashboard.py # 默认 18080
PORT=19090 python3 serve_dashboard.py # 自定义端口
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):
@@ -42,6 +41,15 @@ class Handler(http.server.SimpleHTTPRequestHandler):
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()
@@ -53,23 +61,193 @@ class Handler(http.server.SimpleHTTPRequestHandler):
try:
with open(path, "rb") as f:
data = f.read()
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)
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):
# 走 stderr 让 journalctl 抓到
sys.stderr.write(f"[dashboard] {self.address_string()} - {fmt % args}\n")