修复关键位

This commit is contained in:
dekun
2026-05-22 10:56:42 +08:00
parent d144cb592a
commit 40f7a1b844
15 changed files with 143 additions and 15 deletions
+6
View File
@@ -7649,6 +7649,12 @@ def _hub_meta_bundle():
try: 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 from hub_bridge import install_on_app
install_on_app( install_on_app(
+2 -1
View File
@@ -14,6 +14,7 @@
const path = require("path"); const path = require("path");
const ROOT = __dirname; const ROOT = __dirname;
const REPO_ROOT = path.join(ROOT, "..");
const PY = path.join(ROOT, ".venv", "bin", "python"); const PY = path.join(ROOT, ".venv", "bin", "python");
module.exports = { module.exports = {
@@ -27,7 +28,7 @@ module.exports = {
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: "800M", max_memory_restart: "800M",
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git env: { PYTHONPATH: REPO_ROOT },
}, },
], ],
}; };
+6
View File
@@ -7684,6 +7684,12 @@ def _hub_meta_bundle():
try: 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 from hub_bridge import install_on_app
install_on_app( install_on_app(
+2 -1
View File
@@ -14,6 +14,7 @@
const path = require("path"); const path = require("path");
const ROOT = __dirname; const ROOT = __dirname;
const REPO_ROOT = path.join(ROOT, "..");
const PY = path.join(ROOT, ".venv", "bin", "python"); const PY = path.join(ROOT, ".venv", "bin", "python");
module.exports = { module.exports = {
@@ -27,7 +28,7 @@ module.exports = {
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: "800M", max_memory_restart: "800M",
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git env: { PYTHONPATH: REPO_ROOT },
}, },
], ],
}; };
+6
View File
@@ -7313,6 +7313,12 @@ def _hub_meta_bundle():
try: 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 from hub_bridge import install_on_app
install_on_app( install_on_app(
+2 -1
View File
@@ -14,6 +14,7 @@
const path = require("path"); const path = require("path");
const ROOT = __dirname; const ROOT = __dirname;
const REPO_ROOT = path.join(ROOT, "..");
const PY = path.join(ROOT, ".venv", "bin", "python"); const PY = path.join(ROOT, ".venv", "bin", "python");
module.exports = { module.exports = {
@@ -27,7 +28,7 @@ module.exports = {
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: "800M", max_memory_restart: "800M",
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git env: { PYTHONPATH: REPO_ROOT },
}, },
], ],
}; };
+6
View File
@@ -5925,6 +5925,12 @@ def _hub_meta_bundle():
try: 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 from hub_bridge import install_on_app
install_on_app( install_on_app(
+2 -1
View File
@@ -14,6 +14,7 @@
const path = require("path"); const path = require("path");
const ROOT = __dirname; const ROOT = __dirname;
const REPO_ROOT = path.join(ROOT, "..");
const PY = path.join(ROOT, ".venv", "bin", "python"); const PY = path.join(ROOT, ".venv", "bin", "python");
module.exports = { module.exports = {
@@ -27,7 +28,7 @@ module.exports = {
autorestart: true, autorestart: true,
watch: false, watch: false,
max_memory_restart: "800M", max_memory_restart: "800M",
// app.py 从项目根目录 .env 加载(由 .env.example 复制而来,勿提交 Git env: { PYTHONPATH: REPO_ROOT },
}, },
], ],
}; };
+6
View File
@@ -20,6 +20,12 @@ HUB_DISABLED_IDS=1
# true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1 # true=允许 RFC1918 私网访问中控页面;false=仅 127.0.0.1
HUB_TRUST_LAN=true 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--- # --- 子代理 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
+25 -2
View File
@@ -20,6 +20,7 @@ from settings_store import (
load_settings, load_settings,
save_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_HOST = os.getenv("HUB_HOST", "0.0.0.0")
HUB_PORT = int(os.getenv("HUB_PORT", "5100")) HUB_PORT = int(os.getenv("HUB_PORT", "5100"))
@@ -137,10 +138,17 @@ def api_save_settings(body: SettingsBody):
@app.get("/api/settings/meta") @app.get("/api/settings/meta")
def api_settings_meta(): def api_settings_meta():
po = public_origin()
return { return {
"env_disabled_ids": sorted(env_force_disabled_ids()), "env_disabled_ids": sorted(env_force_disabled_ids()),
"hub_bridge_token_set": bool(HUB_BRIDGE_TOKEN), "hub_bridge_token_set": bool(HUB_BRIDGE_TOKEN),
"capability_options": ["order", "key", "trend"], "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_ok = isinstance(hub_mon, dict) and hub_mon.get("ok") is not False
flask_err = None flask_err = None
if isinstance(hub_mon, dict) and hub_mon.get("ok") is False: 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 = ( flask_err = (
hub_mon.get("msg") hub_mon.get("msg")
or hub_mon.get("error") or hub_mon.get("error")
or (str(hub_mon.get("text") or "")[:200] or None) 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( out.append(
{ {
**agent_row, **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, "hub_monitor": hub_mon,
"flask_ok": flask_ok, "flask_ok": flask_ok,
"flask_error": flask_err, "flask_error": flask_err,
+10 -2
View File
@@ -133,7 +133,13 @@
if ((row.capabilities || []).includes("key")) { if ((row.capabilities || []).includes("key")) {
if (!flaskOk) { if (!flaskOk) {
inner += `<div style="margin-top:8px;font-size:12px;color:#f85149">关键位/机器人:策略 Flask 未连通</div>`; inner += `<div style="margin-top:8px;font-size:12px;color:#f85149">关键位/机器人:策略 Flask 未连通</div>`;
inner += `<div class="rule-tip">${esc(row.flask_error || hm.msg || "请确认实例 app 已启动,且 HUB_BRIDGE_TOKEN 与实例一致或 APP_AUTH_DISABLED=true")}</div>`; const fe = row.flask_error || hm.msg || hm.error || "";
const short =
fe ||
(hm.status === 404
? "HTTP 404:请 git pull 并重启各 crypto_* Flaskhub_bridge 路由未注册)"
: "请确认实例已启动,且 HUB_BRIDGE_TOKEN 与实例一致或 APP_AUTH_DISABLED=true");
inner += `<div class="rule-tip">${esc(short)}</div>`;
} else if (!keys.length) { } else if (!keys.length) {
inner += `<div style="margin-top:8px;color:var(--muted);font-size:12px">关键位:当前无记录(在下单区或实例首页添加)</div>`; inner += `<div style="margin-top:8px;color:var(--muted);font-size:12px">关键位:当前无记录(在下单区或实例首页添加)</div>`;
} else { } else {
@@ -163,7 +169,7 @@
: ""; : "";
return `<div class="card"> return `<div class="card">
<div class="card-head"> <div class="card-head">
<div><strong>${esc(row.name)}</strong><div class="rule-tip">${esc(row.flask_url || "")}</div></div> <div><strong>${esc(row.name)}</strong><div class="rule-tip">${esc(row.flask_url_browser || row.flask_url || "")}</div></div>
<div style="display:flex;gap:8px;align-items:center"> <div style="display:flex;gap:8px;align-items:center">
${review} ${review}
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">该户全平</button> <button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">该户全平</button>
@@ -349,6 +355,8 @@
const parts = []; const parts = [];
if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN"); if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN");
else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)"); 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) if ((m.env_disabled_ids || []).length)
parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", ")); parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", "));
el.textContent = parts.join(" · "); el.textContent = parts.join(" · ");
+2 -1
View File
@@ -17,7 +17,8 @@
<h1>监控区</h1> <h1>监控区</h1>
<p class="rule-tip" style="margin-top:0"> <p class="rule-tip" style="margin-top:0">
持仓/余额来自子代理;关键位、机器人单来自各实例 Flask(须 PM2 跑着 crypto_*)。 持仓/余额来自子代理;关键位、机器人单来自各实例 Flask(须 PM2 跑着 crypto_*)。
卡片右上角「交易复盘」= 打开该所交易记录页,不在中控里做复盘。 「交易复盘」在新标签打开 /records。其它电脑访问中控时,请在 hub 的 .env 设置
HUB_PUBLIC_ORIGIN=http://Ubuntu内网IP,否则会跳到 127.0.0.1。
</p> </p>
<div class="toolbar"> <div class="toolbar">
<button type="button" id="btn-monitor-refresh">立即刷新</button> <button type="button" id="btn-monitor-refresh">立即刷新</button>
+61
View File
@@ -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"
+1 -1
View File
@@ -154,7 +154,7 @@ python hub.py
| **机器人持仓** | 来自实例 `/api/hub/monitor``order_monitors`active | | **机器人持仓** | 来自实例 `/api/hub/monitor``order_monitors`active |
| **关键位** | 仅 `capabilities``key` 的户;展示门控摘要(`/api/price_snapshot` | | **关键位** | 仅 `capabilities``key` 的户;展示门控摘要(`/api/price_snapshot` |
| **趋势计划** | 仅 Gate 趋势户;`trend_pullback_plans` active | | **趋势计划** | 仅 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 趋势户**无关键位 | | **关键位** | 来自实例 `/api/hub/monitor` + `/api/price_snapshot`(须 Flask 已启动);无记录或 Flask 未连通时卡片会提示原因;**Gate 趋势户**无关键位 |
| **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 | | **该户全平** | `POST` 子代理 `/emergency/close-all`,仅平该 API Key 仓位 |
| **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id | | **全局紧急全平** | 对所有已启用户依次全平(不含 `HUB_DISABLED_IDS` 强制关闭的 id |
+2 -1
View File
@@ -177,7 +177,7 @@ curl -s http://127.0.0.1:15200/health
2. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**。 2. `hub_settings.json` 中 Flask/Agent 保持 **`http://127.0.0.1:...`**。
3. 四实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**(见 `.env.example` 注释)。 3. 四实例 **`APP_AUTH_DISABLED=false`** + 与中控相同 **`HUB_BRIDGE_TOKEN`**(见 `.env.example` 注释)。
4. 子代理 **`HOST=127.0.0.1`**,防火墙勿放行 `15200``15203`、各 `APP_PORT` 4. 子代理 **`HOST=127.0.0.1`**,防火墙勿放行 `15200``15203`、各 `APP_PORT`
5. 监控页「复盘」链到本机 Flask,公网浏览器通常打不开,需 VPN/内网访问实例。 5. **交易复盘**`manual_trading_hub/.env``HUB_PUBLIC_ORIGIN=http://<Ubuntu局域网IP>`;内网其它设备才能打开复盘;须能访问实例端口(5000/5001/5002)或单独反代
--- ---
@@ -189,6 +189,7 @@ curl -s http://127.0.0.1:15200/health
| `HUB_PORT` | `5100` | 端口 | | `HUB_PORT` | `5100` | 端口 |
| `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 id(OKX) | | `HUB_DISABLED_IDS` | `1` | 强制关闭的账户 id(OKX) |
| `HUB_TRUST_LAN` | `true` | 私网可访问;仅本机可 `false` | | `HUB_TRUST_LAN` | `true` | 私网可访问;仅本机可 `false` |
| `HUB_PUBLIC_ORIGIN` | 空 | 浏览器用复盘链接;如 `http://192.168.1.100`(**内网其它电脑访问中控时建议设置**) |
| `HUB_BRIDGE_TOKEN` | 空 | 公网/开登录时建议配置 | | `HUB_BRIDGE_TOKEN` | 空 | 公网/开登录时建议配置 |
本地联调、实例 `APP_AUTH_DISABLED=true` 时可不配 `HUB_BRIDGE_TOKEN` 本地联调、实例 `APP_AUTH_DISABLED=true` 时可不配 `HUB_BRIDGE_TOKEN`