修复中控
This commit is contained in:
@@ -36,6 +36,12 @@ HUB_TRUST_LAN=true
|
||||
# 或只写主机名:HUB_PUBLIC_HOST=192.168.1.100
|
||||
# HUB_PUBLIC_SCHEME=http
|
||||
|
||||
# 监控区 /api/monitor/board 聚合超时(秒,默认 agent 8 / flask 10)
|
||||
# HUB_AGENT_TIMEOUT=8
|
||||
# HUB_FLASK_TIMEOUT=10
|
||||
# 为 false 时不拉各实例 /api/price_snapshot(关键位门控简化为「-」,首屏明显更快)
|
||||
# HUB_BOARD_KEY_PRICES=true
|
||||
|
||||
# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT)---
|
||||
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token
|
||||
# CONTROL_TOKEN=your-long-random-token
|
||||
|
||||
+83
-48
@@ -43,7 +43,11 @@ HUB_BRIDGE_TOKEN = (os.getenv("HUB_BRIDGE_TOKEN") or os.getenv("CONTROL_TOKEN")
|
||||
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
|
||||
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
|
||||
DIR = Path(__file__).resolve().parent
|
||||
HUB_BUILD = "20260525-mobile"
|
||||
HUB_BUILD = "20260525-perf"
|
||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||
_board_key_prices_raw = (os.getenv("HUB_BOARD_KEY_PRICES", "true") or "").strip().lower()
|
||||
HUB_BOARD_KEY_PRICES = _board_key_prices_raw in ("1", "true", "yes", "on")
|
||||
|
||||
|
||||
def _is_local(host: str | None) -> bool:
|
||||
@@ -261,7 +265,7 @@ def api_settings_meta():
|
||||
async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict:
|
||||
url = f"{ex['agent_url'].rstrip('/')}/status"
|
||||
try:
|
||||
r = await client.get(url, headers=_agent_headers(), timeout=12.0)
|
||||
r = await client.get(url, headers=_agent_headers(), timeout=HUB_AGENT_TIMEOUT)
|
||||
body = r.json() if r.content else {}
|
||||
return {
|
||||
"id": ex["id"],
|
||||
@@ -317,7 +321,7 @@ async def _fetch_flask_json(
|
||||
return None
|
||||
try:
|
||||
if method == "GET":
|
||||
r = await client.get(f"{base}{path}", headers=_hub_headers(), timeout=15.0)
|
||||
r = await client.get(f"{base}{path}", headers=_hub_headers(), timeout=HUB_FLASK_TIMEOUT)
|
||||
else:
|
||||
r = await client.post(f"{base}{path}", headers=_hub_headers(), data=data, timeout=120.0)
|
||||
if r.status_code >= 400:
|
||||
@@ -330,55 +334,86 @@ async def _fetch_flask_json(
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def _flask_error_from_hub_mon(hub_mon: dict | None) -> str | None:
|
||||
if not isinstance(hub_mon, dict) or hub_mon.get("ok") is not False:
|
||||
return None
|
||||
st = hub_mon.get("status")
|
||||
if st == 404:
|
||||
return (
|
||||
"HTTP 404:该 Flask 未注册 /api/hub/*(hub_bridge 未加载)。"
|
||||
"请在仓库根目录 git pull 后 pm2 restart crypto_binance crypto_gate crypto_gate_bot,"
|
||||
"并查看启动日志是否含 [hub_bridge] ImportError"
|
||||
)
|
||||
return (
|
||||
hub_mon.get("msg")
|
||||
or hub_mon.get("error")
|
||||
or (f"HTTP {st}" if st else None)
|
||||
or (str(hub_mon.get("text") or "")[:120] or None)
|
||||
)
|
||||
|
||||
|
||||
async def _fetch_exchange_flask_bundle(
|
||||
client: httpx.AsyncClient, ex: dict
|
||||
) -> tuple[dict | None, dict | None, list | None]:
|
||||
"""单所 Flask:monitor / meta /(可选)price_snapshot 并行拉取。"""
|
||||
caps = ex.get("capabilities") or []
|
||||
tasks = [
|
||||
_fetch_flask_json(client, ex, "/api/hub/monitor"),
|
||||
_fetch_flask_json(client, ex, "/api/hub/meta"),
|
||||
]
|
||||
want_prices = HUB_BOARD_KEY_PRICES and "key" in caps
|
||||
if want_prices:
|
||||
tasks.append(_fetch_flask_json(client, ex, "/api/price_snapshot"))
|
||||
results = await asyncio.gather(*tasks)
|
||||
hub_mon = results[0]
|
||||
meta = results[1]
|
||||
key_prices = None
|
||||
if want_prices and len(results) > 2:
|
||||
snap = results[2]
|
||||
if isinstance(snap, dict):
|
||||
key_prices = snap.get("key_prices")
|
||||
return hub_mon, meta, key_prices
|
||||
|
||||
|
||||
async def _assemble_board_row(
|
||||
client: httpx.AsyncClient, ex: dict, agent_row: dict
|
||||
) -> dict:
|
||||
hub_mon, meta, key_prices = await _fetch_exchange_flask_bundle(client, ex)
|
||||
flask_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False
|
||||
raw_review = (ex.get("review_url") or "").strip()
|
||||
review_link = browser_url(raw_review) if raw_review else default_review_url(
|
||||
ex.get("flask_url")
|
||||
)
|
||||
return {
|
||||
**agent_row,
|
||||
"flask_url": ex.get("flask_url") or "",
|
||||
"flask_url_browser": browser_url(ex.get("flask_url")),
|
||||
"review_url": review_link,
|
||||
"hub_monitor": hub_mon,
|
||||
"flask_ok": flask_ok,
|
||||
"flask_error": _flask_error_from_hub_mon(hub_mon if isinstance(hub_mon, dict) else None),
|
||||
"meta": (meta or {}).get("meta") if isinstance(meta, dict) else meta,
|
||||
"key_prices": key_prices,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/monitor/board")
|
||||
async def api_monitor_board():
|
||||
exchanges = enabled_exchanges()
|
||||
async with httpx.AsyncClient() as client:
|
||||
agent_rows = await asyncio.gather(*[_fetch_agent_status(client, ex) for ex in exchanges])
|
||||
out = []
|
||||
for ex, agent_row in zip(exchanges, agent_rows):
|
||||
hub_mon = await _fetch_flask_json(client, ex, "/api/hub/monitor")
|
||||
meta = await _fetch_flask_json(client, ex, "/api/hub/meta")
|
||||
key_prices = None
|
||||
if "key" in (ex.get("capabilities") or []):
|
||||
snap = await _fetch_flask_json(client, ex, "/api/price_snapshot")
|
||||
if isinstance(snap, dict):
|
||||
key_prices = snap.get("key_prices")
|
||||
flask_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False
|
||||
flask_err = None
|
||||
if isinstance(hub_mon, dict) and hub_mon.get("ok") is False:
|
||||
st = hub_mon.get("status")
|
||||
if st == 404:
|
||||
flask_err = (
|
||||
"HTTP 404:该 Flask 未注册 /api/hub/*(hub_bridge 未加载)。"
|
||||
"请在仓库根目录 git pull 后 pm2 restart crypto_binance crypto_gate crypto_gate_bot,"
|
||||
"并查看启动日志是否含 [hub_bridge] ImportError"
|
||||
)
|
||||
else:
|
||||
flask_err = (
|
||||
hub_mon.get("msg")
|
||||
or hub_mon.get("error")
|
||||
or (f"HTTP {st}" if st else None)
|
||||
or (str(hub_mon.get("text") or "")[:120] or None)
|
||||
)
|
||||
raw_review = (ex.get("review_url") or "").strip()
|
||||
review_link = browser_url(raw_review) if raw_review else default_review_url(
|
||||
ex.get("flask_url")
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
**agent_row,
|
||||
"flask_url": ex.get("flask_url") or "",
|
||||
"flask_url_browser": browser_url(ex.get("flask_url")),
|
||||
"review_url": review_link,
|
||||
"hub_monitor": hub_mon,
|
||||
"flask_ok": flask_ok,
|
||||
"flask_error": flask_err,
|
||||
"meta": (meta or {}).get("meta") if isinstance(meta, dict) else meta,
|
||||
"key_prices": key_prices,
|
||||
}
|
||||
)
|
||||
return {"rows": out, "updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds")}
|
||||
agent_rows = await asyncio.gather(
|
||||
*[_fetch_agent_status(client, ex) for ex in exchanges]
|
||||
)
|
||||
out = await asyncio.gather(
|
||||
*[
|
||||
_assemble_board_row(client, ex, agent_row)
|
||||
for ex, agent_row in zip(exchanges, agent_rows)
|
||||
]
|
||||
)
|
||||
return {
|
||||
"rows": list(out),
|
||||
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
class CloseAllBody(BaseModel):
|
||||
|
||||
@@ -1238,6 +1238,36 @@ button.btn-sm {
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.board-loading {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
min-height: 120px;
|
||||
padding: 24px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
border: 1px dashed var(--border-soft);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.board-loading-spin {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-soft);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: hub-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hub-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.pnl-pos {
|
||||
color: var(--green);
|
||||
text-shadow: 0 0 12px rgba(0, 255, 157, 0.3);
|
||||
|
||||
@@ -198,6 +198,11 @@
|
||||
|
||||
async function loadMonitorBoard() {
|
||||
const box = document.getElementById("monitor-grid");
|
||||
const showLoading = !lastMonitorRows.length;
|
||||
if (showLoading && box) {
|
||||
box.innerHTML =
|
||||
'<div class="board-loading"><span class="board-loading-spin" aria-hidden="true"></span>正在聚合四所数据…</div>';
|
||||
}
|
||||
try {
|
||||
const r = await apiFetch("/api/monitor/board");
|
||||
const data = await r.json();
|
||||
@@ -1125,8 +1130,10 @@
|
||||
|
||||
initAuth().then((ok) => {
|
||||
if (!ok) return;
|
||||
loadSettings().catch(() => {});
|
||||
setActiveNav();
|
||||
if (currentPage() === "settings") {
|
||||
loadSettings().catch(() => {});
|
||||
}
|
||||
window.addEventListener("popstate", setActiveNav);
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<title>复盘系统中控</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260525-mobile" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260525-perf" />
|
||||
</head>
|
||||
<body>
|
||||
<div class="app-bg" aria-hidden="true"></div>
|
||||
@@ -108,6 +109,6 @@
|
||||
</div>
|
||||
|
||||
<div id="toast"></div>
|
||||
<script src="/assets/app.js?v=20260525-mobile"></script>
|
||||
<script src="/assets/app.js?v=20260525-perf"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -6,8 +6,9 @@
|
||||
<title>登录 · 复盘系统中控</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260525-mobile" />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260525-perf" />
|
||||
</head>
|
||||
<body class="login-page">
|
||||
<div class="login-bg" aria-hidden="true"></div>
|
||||
|
||||
@@ -150,6 +150,26 @@ cd manual_trading_hub && pm2 restart manual-agent-gate manual-agent-gate-bot
|
||||
curl -s -H "X-Hub-Token:你的令牌" http://127.0.0.1:5000/api/hub/ping
|
||||
```
|
||||
|
||||
### 3.3 中控监控区打开慢、一直转圈
|
||||
|
||||
**原因(常见)**:
|
||||
|
||||
1. 首屏要等 **`/api/monitor/board`**:向 4 个子代理拉持仓/余额,并向 4 个 Flask 拉监控与(默认)关键位行情;任一实例慢或超时都会拖住整页。
|
||||
2. 旧版 hub 对每所 Flask **串行**请求,4 所 × 3 接口容易累计到十几秒;新版已改为**并行**(`git pull` 后 `pm2 restart manual-trading-hub`)。
|
||||
3. 各实例 **`/api/price_snapshot`** 会调交易所接口(含全量持仓),最耗时;内网访问 Google 字体也会拖首屏渲染。
|
||||
4. 子代理 `/status` 里 `fetch_balance` / `fetch_positions` / 挂单列表走交易所 API,网络差时单次可达数秒。
|
||||
|
||||
**加快办法**:
|
||||
|
||||
```env
|
||||
# manual_trading_hub/.env
|
||||
HUB_BOARD_KEY_PRICES=false # 不拉 price_snapshot,关键位门控显示为「-」,首屏明显更快
|
||||
HUB_AGENT_TIMEOUT=6
|
||||
HUB_FLASK_TIMEOUT=8
|
||||
```
|
||||
|
||||
并确认四所 `crypto_*` 与 `manual-agent-*` 均为 **online**,避免等满超时。浏览器 **Ctrl+F5** 强刷静态资源(版本号含 `20260525-perf`)。
|
||||
|
||||
---
|
||||
|
||||
## 四、复盘链接与公网反代
|
||||
|
||||
Reference in New Issue
Block a user