diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 642ed7c..2bb46d9 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -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 diff --git a/manual_trading_hub/hub.py b/manual_trading_hub/hub.py index a127366..2425cc7 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -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): diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index e7718e1..ed77791 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -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); diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index a717b13..56bec96 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -198,6 +198,11 @@ async function loadMonitorBoard() { const box = document.getElementById("monitor-grid"); + const showLoading = !lastMonitorRows.length; + if (showLoading && box) { + box.innerHTML = + '