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:
@@ -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
@@ -7,24 +7,23 @@
|
||||
路由:
|
||||
GET / -> 重定向到 /bot.html
|
||||
GET /bot.html -> 中文实时仪表盘
|
||||
GET /api/feed -> live/copybot_live.json(bot 写入的)
|
||||
GET /api/portfolio -> live/portfolio.json(portfolio.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.json(paper bot feed)
|
||||
GET /api/live -> live/copybot_live_real.json(live 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")
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user