修复中控
This commit is contained in:
@@ -36,6 +36,12 @@ HUB_TRUST_LAN=true
|
|||||||
# 或只写主机名:HUB_PUBLIC_HOST=192.168.1.100
|
# 或只写主机名:HUB_PUBLIC_HOST=192.168.1.100
|
||||||
# HUB_PUBLIC_SCHEME=http
|
# 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)---
|
# --- 子代理 agent.py(在 crypto_monitor_* 目录启动时另设 EXCHANGE / PORT)---
|
||||||
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token
|
# 与 HUB_BRIDGE_TOKEN 一致时可只设其一;agent 校验请求头 X-Control-Token
|
||||||
# CONTROL_TOKEN=your-long-random-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()
|
_trust_raw = (os.getenv("HUB_TRUST_LAN", "true") or "").strip().lower()
|
||||||
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
|
HUB_TRUST_LAN = _trust_raw not in ("0", "false", "no", "off")
|
||||||
DIR = Path(__file__).resolve().parent
|
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:
|
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:
|
async def _fetch_agent_status(client: httpx.AsyncClient, ex: dict) -> dict:
|
||||||
url = f"{ex['agent_url'].rstrip('/')}/status"
|
url = f"{ex['agent_url'].rstrip('/')}/status"
|
||||||
try:
|
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 {}
|
body = r.json() if r.content else {}
|
||||||
return {
|
return {
|
||||||
"id": ex["id"],
|
"id": ex["id"],
|
||||||
@@ -317,7 +321,7 @@ async def _fetch_flask_json(
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
if method == "GET":
|
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:
|
else:
|
||||||
r = await client.post(f"{base}{path}", headers=_hub_headers(), data=data, timeout=120.0)
|
r = await client.post(f"{base}{path}", headers=_hub_headers(), data=data, timeout=120.0)
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
@@ -330,55 +334,86 @@ async def _fetch_flask_json(
|
|||||||
return {"ok": False, "error": str(e)}
|
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")
|
@app.get("/api/monitor/board")
|
||||||
async def api_monitor_board():
|
async def api_monitor_board():
|
||||||
exchanges = enabled_exchanges()
|
exchanges = enabled_exchanges()
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient() as client:
|
||||||
agent_rows = await asyncio.gather(*[_fetch_agent_status(client, ex) for ex in exchanges])
|
agent_rows = await asyncio.gather(
|
||||||
out = []
|
*[_fetch_agent_status(client, ex) for ex in exchanges]
|
||||||
for ex, agent_row in zip(exchanges, agent_rows):
|
)
|
||||||
hub_mon = await _fetch_flask_json(client, ex, "/api/hub/monitor")
|
out = await asyncio.gather(
|
||||||
meta = await _fetch_flask_json(client, ex, "/api/hub/meta")
|
*[
|
||||||
key_prices = None
|
_assemble_board_row(client, ex, agent_row)
|
||||||
if "key" in (ex.get("capabilities") or []):
|
for ex, agent_row in zip(exchanges, agent_rows)
|
||||||
snap = await _fetch_flask_json(client, ex, "/api/price_snapshot")
|
]
|
||||||
if isinstance(snap, dict):
|
)
|
||||||
key_prices = snap.get("key_prices")
|
return {
|
||||||
flask_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False
|
"rows": list(out),
|
||||||
flask_err = None
|
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
|
||||||
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")}
|
|
||||||
|
|
||||||
|
|
||||||
class CloseAllBody(BaseModel):
|
class CloseAllBody(BaseModel):
|
||||||
|
|||||||
@@ -1238,6 +1238,36 @@ button.btn-sm {
|
|||||||
padding: 8px 0;
|
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 {
|
.pnl-pos {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
text-shadow: 0 0 12px rgba(0, 255, 157, 0.3);
|
text-shadow: 0 0 12px rgba(0, 255, 157, 0.3);
|
||||||
|
|||||||
@@ -198,6 +198,11 @@
|
|||||||
|
|
||||||
async function loadMonitorBoard() {
|
async function loadMonitorBoard() {
|
||||||
const box = document.getElementById("monitor-grid");
|
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 {
|
try {
|
||||||
const r = await apiFetch("/api/monitor/board");
|
const r = await apiFetch("/api/monitor/board");
|
||||||
const data = await r.json();
|
const data = await r.json();
|
||||||
@@ -1125,8 +1130,10 @@
|
|||||||
|
|
||||||
initAuth().then((ok) => {
|
initAuth().then((ok) => {
|
||||||
if (!ok) return;
|
if (!ok) return;
|
||||||
loadSettings().catch(() => {});
|
|
||||||
setActiveNav();
|
setActiveNav();
|
||||||
|
if (currentPage() === "settings") {
|
||||||
|
loadSettings().catch(() => {});
|
||||||
|
}
|
||||||
window.addEventListener("popstate", setActiveNav);
|
window.addEventListener("popstate", setActiveNav);
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
<title>复盘系统中控</title>
|
<title>复盘系统中控</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 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'" />
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260525-mobile" />
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app-bg" aria-hidden="true"></div>
|
<div class="app-bg" aria-hidden="true"></div>
|
||||||
@@ -108,6 +109,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="toast"></div>
|
<div id="toast"></div>
|
||||||
<script src="/assets/app.js?v=20260525-mobile"></script>
|
<script src="/assets/app.js?v=20260525-perf"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,8 +6,9 @@
|
|||||||
<title>登录 · 复盘系统中控</title>
|
<title>登录 · 复盘系统中控</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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 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'" />
|
||||||
<link rel="stylesheet" href="/assets/app.css?v=20260525-mobile" />
|
<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>
|
</head>
|
||||||
<body class="login-page">
|
<body class="login-page">
|
||||||
<div class="login-bg" aria-hidden="true"></div>
|
<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
|
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