diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 36d043c..d85e836 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -7649,6 +7649,12 @@ def _hub_meta_bundle(): try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) from hub_bridge import install_on_app install_on_app( diff --git a/crypto_monitor_binance/ecosystem.config.cjs b/crypto_monitor_binance/ecosystem.config.cjs index 23e6255..2f7c221 100644 --- a/crypto_monitor_binance/ecosystem.config.cjs +++ b/crypto_monitor_binance/ecosystem.config.cjs @@ -14,6 +14,7 @@ const path = require("path"); const ROOT = __dirname; +const REPO_ROOT = path.join(ROOT, ".."); const PY = path.join(ROOT, ".venv", "bin", "python"); module.exports = { @@ -27,7 +28,7 @@ module.exports = { autorestart: true, watch: false, max_memory_restart: "800M", - // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + env: { PYTHONPATH: REPO_ROOT }, }, ], }; diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 84a6157..04c5597 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -7684,6 +7684,12 @@ def _hub_meta_bundle(): try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) from hub_bridge import install_on_app install_on_app( diff --git a/crypto_monitor_gate/ecosystem.config.cjs b/crypto_monitor_gate/ecosystem.config.cjs index 097bf52..8fa8d8f 100644 --- a/crypto_monitor_gate/ecosystem.config.cjs +++ b/crypto_monitor_gate/ecosystem.config.cjs @@ -14,6 +14,7 @@ const path = require("path"); const ROOT = __dirname; +const REPO_ROOT = path.join(ROOT, ".."); const PY = path.join(ROOT, ".venv", "bin", "python"); module.exports = { @@ -27,7 +28,7 @@ module.exports = { autorestart: true, watch: false, max_memory_restart: "800M", - // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + env: { PYTHONPATH: REPO_ROOT }, }, ], }; diff --git a/crypto_monitor_gate_bot/app.py b/crypto_monitor_gate_bot/app.py index d333d24..5675a30 100644 --- a/crypto_monitor_gate_bot/app.py +++ b/crypto_monitor_gate_bot/app.py @@ -7313,6 +7313,12 @@ def _hub_meta_bundle(): try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) from hub_bridge import install_on_app install_on_app( diff --git a/crypto_monitor_gate_bot/ecosystem.config.cjs b/crypto_monitor_gate_bot/ecosystem.config.cjs index 536fec5..f1e4926 100644 --- a/crypto_monitor_gate_bot/ecosystem.config.cjs +++ b/crypto_monitor_gate_bot/ecosystem.config.cjs @@ -14,6 +14,7 @@ const path = require("path"); const ROOT = __dirname; +const REPO_ROOT = path.join(ROOT, ".."); const PY = path.join(ROOT, ".venv", "bin", "python"); module.exports = { @@ -27,7 +28,7 @@ module.exports = { autorestart: true, watch: false, max_memory_restart: "800M", - // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + env: { PYTHONPATH: REPO_ROOT }, }, ], }; diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 0d01a77..79c6636 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -5925,6 +5925,12 @@ def _hub_meta_bundle(): try: + import sys + from pathlib import Path + + _repo_root = Path(__file__).resolve().parent.parent + if str(_repo_root) not in sys.path: + sys.path.insert(0, str(_repo_root)) from hub_bridge import install_on_app install_on_app( diff --git a/crypto_monitor_okx/ecosystem.config.cjs b/crypto_monitor_okx/ecosystem.config.cjs index adf8ac4..7183c40 100644 --- a/crypto_monitor_okx/ecosystem.config.cjs +++ b/crypto_monitor_okx/ecosystem.config.cjs @@ -14,6 +14,7 @@ const path = require("path"); const ROOT = __dirname; +const REPO_ROOT = path.join(ROOT, ".."); const PY = path.join(ROOT, ".venv", "bin", "python"); module.exports = { @@ -27,7 +28,7 @@ module.exports = { autorestart: true, watch: false, max_memory_restart: "800M", - // app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git) + env: { PYTHONPATH: REPO_ROOT }, }, ], }; diff --git a/manual_trading_hub/.env.example b/manual_trading_hub/.env.example index 59f50c1..cfe90a4 100644 --- a/manual_trading_hub/.env.example +++ b/manual_trading_hub/.env.example @@ -20,6 +20,12 @@ HUB_DISABLED_IDS=1 # true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1 HUB_TRUST_LAN=true +# 浏览器打开的复盘/实例链接:把 127.0.0.1 换成 Ubuntu 内网 IP 或域名(中控本机调 API 仍用 127.0.0.1) +# 例:用手机/另一台电脑访问中控时必填,否则「交易复盘」会指向你自己电脑的 localhost +# HUB_PUBLIC_ORIGIN=http://192.168.1.100 +# 或只写主机名:HUB_PUBLIC_HOST=192.168.1.100 +# HUB_PUBLIC_SCHEME=http + # --- 子代理 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 772fbb4..6354373 100644 --- a/manual_trading_hub/hub.py +++ b/manual_trading_hub/hub.py @@ -20,6 +20,7 @@ from settings_store import ( load_settings, save_settings, ) +from url_public import browser_url, default_review_url, public_origin HUB_HOST = os.getenv("HUB_HOST", "0.0.0.0") HUB_PORT = int(os.getenv("HUB_PORT", "5100")) @@ -137,10 +138,17 @@ def api_save_settings(body: SettingsBody): @app.get("/api/settings/meta") def api_settings_meta(): + po = public_origin() return { "env_disabled_ids": sorted(env_force_disabled_ids()), "hub_bridge_token_set": bool(HUB_BRIDGE_TOKEN), "capability_options": ["order", "key", "trend"], + "public_origin": f"{po[0]}://{po[1]}" if po else None, + "public_origin_hint": ( + "未设置 HUB_PUBLIC_ORIGIN 时,复盘链接若为 127.0.0.1,仅服务器本机浏览器可打开" + if not po + else "复盘/展示链接已替换为对外地址" + ), } @@ -209,15 +217,30 @@ async def api_monitor_board(): 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: - flask_err = ( - hub_mon.get("msg") - or hub_mon.get("error") - or (str(hub_mon.get("text") or "")[:200] or None) - ) + 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, - "review_url": ex.get("review_url") or "", + "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, diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index 75097f0..ed4a4e0 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -133,7 +133,13 @@ if ((row.capabilities || []).includes("key")) { if (!flaskOk) { inner += `
关键位/机器人:策略 Flask 未连通
`; - inner += `
${esc(row.flask_error || hm.msg || "请确认实例 app 已启动,且 HUB_BRIDGE_TOKEN 与实例一致或 APP_AUTH_DISABLED=true")}
`; + const fe = row.flask_error || hm.msg || hm.error || ""; + const short = + fe || + (hm.status === 404 + ? "HTTP 404:请 git pull 并重启各 crypto_* Flask(hub_bridge 路由未注册)" + : "请确认实例已启动,且 HUB_BRIDGE_TOKEN 与实例一致或 APP_AUTH_DISABLED=true"); + inner += `
${esc(short)}
`; } else if (!keys.length) { inner += `
关键位:当前无记录(在下单区或实例首页添加)
`; } else { @@ -163,7 +169,7 @@ : ""; return `
-
${esc(row.name)}
${esc(row.flask_url || "")}
+
${esc(row.name)}
${esc(row.flask_url_browser || row.flask_url || "")}
${review} @@ -349,6 +355,8 @@ const parts = []; if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); + if (m.public_origin) parts.push("复盘外链: " + m.public_origin); + else parts.push("未设 HUB_PUBLIC_ORIGIN(复盘 127.0.0.1 仅服务器本机可开)"); if ((m.env_disabled_ids || []).length) parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ")); el.textContent = parts.join(" · "); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index fbe408e..82f50dd 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -17,7 +17,8 @@

监控区

持仓/余额来自子代理;关键位、机器人单来自各实例 Flask(须 PM2 跑着 crypto_*)。 - 卡片右上角「交易复盘」= 打开该所交易记录页,不在中控里做复盘。 + 「交易复盘」在新标签打开 /records。其它电脑访问中控时,请在 hub 的 .env 设置 + HUB_PUBLIC_ORIGIN=http://Ubuntu内网IP,否则会跳到 127.0.0.1。

diff --git a/manual_trading_hub/url_public.py b/manual_trading_hub/url_public.py new file mode 100644 index 0000000..a66da70 --- /dev/null +++ b/manual_trading_hub/url_public.py @@ -0,0 +1,61 @@ +"""将 127.0.0.1 服务地址转为浏览器可访问的外链(内网 IP 或域名)。""" + +from __future__ import annotations + +import os +from urllib.parse import urlparse, urlunparse + +_LOCAL_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + + +def public_origin() -> tuple[str, str] | None: + """ + 从环境变量读取对外 Origin。 + HUB_PUBLIC_ORIGIN=http://192.168.1.10 或 HUB_PUBLIC_HOST=192.168.1.10 + """ + raw = (os.getenv("HUB_PUBLIC_ORIGIN") or os.getenv("HUB_PUBLIC_HOST") or "").strip() + if not raw: + return None + if "://" in raw: + p = urlparse(raw) + scheme = (p.scheme or "http").strip() + host = (p.hostname or "").strip() + if not host: + return None + return scheme, host + scheme = (os.getenv("HUB_PUBLIC_SCHEME") or "http").strip() or "http" + host = raw.split("/")[0].split(":")[0].strip() + return (scheme, host) if host else None + + +def browser_url(internal_url: str | None) -> str: + """ + 中控本机请求仍用 internal_url;返回给前端、复盘链接用本函数。 + 若未配置 HUB_PUBLIC_* 或原 URL 已是非本机地址,则原样返回。 + """ + if not internal_url or not str(internal_url).strip(): + return "" + u = str(internal_url).strip() + origin = public_origin() + if not origin: + return u + scheme_pub, host_pub = origin + try: + p = urlparse(u) + except Exception: + return u + if not p.scheme or not p.netloc: + return u + host = (p.hostname or "").lower() + if host not in _LOCAL_HOSTS and not host.startswith("::ffff:127.0.0.1"): + return u + port = p.port + netloc = f"{host_pub}:{port}" if port else host_pub + return urlunparse((scheme_pub, netloc, p.path or "", p.params, p.query, p.fragment)) + + +def default_review_url(flask_url: str | None) -> str: + base = browser_url((flask_url or "").rstrip("/")) + if not base: + return "" + return f"{base}/records" diff --git a/manual_trading_hub/使用说明.md b/manual_trading_hub/使用说明.md index f3220ad..9d6f57a 100644 --- a/manual_trading_hub/使用说明.md +++ b/manual_trading_hub/使用说明.md @@ -154,7 +154,7 @@ python hub.py | **机器人持仓** | 来自实例 `/api/hub/monitor` 的 `order_monitors`(active) | | **关键位** | 仅 `capabilities` 含 `key` 的户;展示门控摘要(`/api/price_snapshot`) | | **趋势计划** | 仅 Gate 趋势户;`trend_pullback_plans` active | -| **交易复盘** | 新标签打开该户 Flask 的 `/records`(交易记录、笔记、导出 CSV);**中控不做复盘**,仅跳转 | +| **交易复盘** | 新标签打开该户 `/records`;**中控不做复盘**。链接默认由 `flask_url` 生成;若配置 **`HUB_PUBLIC_ORIGIN`**(如 `http://192.168.x.x`),会把 `127.0.0.1` 换成 Ubuntu 内网 IP,方便局域网其它设备打开 | | **关键位** | 来自实例 `/api/hub/monitor` + `/api/price_snapshot`(须 Flask 已启动);无记录或 Flask 未连通时卡片会提示原因;**Gate 趋势户**无关键位 | | **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 | | **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id) | diff --git a/manual_trading_hub/部署文档.md b/manual_trading_hub/部署文档.md index 25ab462..40fcbdb 100644 --- a/manual_trading_hub/部署文档.md +++ b/manual_trading_hub/部署文档.md @@ -177,7 +177,7 @@ curl -s http://127.0.0.1:15200/health 2. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**。 3. 四实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**(见 `.env.example` 注释)。 4. 子代理 **`HOST=127.0.0.1`**,防火墙勿放行 `15200`~`15203`、各 `APP_PORT`。 -5. 监控页「复盘」链到本机 Flask,公网浏览器通常打不开,需 VPN/内网访问实例。 +5. **交易复盘**:`manual_trading_hub/.env` 设 `HUB_PUBLIC_ORIGIN=http://`;内网其它设备才能打开复盘;须能访问各实例端口(5000/5001/5002)或单独反代。 --- @@ -189,6 +189,7 @@ curl -s http://127.0.0.1:15200/health | `HUB_PORT` | `5100` | 端口 | | `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 id(OKX) | | `HUB_TRUST_LAN` | `true` | 私网可访问;仅本机可 `false` | +| `HUB_PUBLIC_ORIGIN` | 空 | 浏览器用复盘链接;如 `http://192.168.1.100`(**内网其它电脑访问中控时建议设置**) | | `HUB_BRIDGE_TOKEN` | 空 | 公网/开登录时建议配置 | 本地联调、实例 `APP_AUTH_DISABLED=true` 时可不配 `HUB_BRIDGE_TOKEN`。