feat(hub): background board poll every 5s with SSE snapshot updates

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-03 22:17:23 +08:00
parent 6a76993ca8
commit 5b6babd699
6 changed files with 409 additions and 76 deletions
+3 -1
View File
@@ -56,7 +56,9 @@ HUB_TRUST_LAN=true
# 四实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输) # 四实例网页登录(直链反代/IP:端口 访问时输入;中控点「打开实例」免输)
# 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=... # 各 crypto_monitor_*/.env 统一:APP_USERNAME=... APP_PASSWORD=...
# 监控区 /api/monitor/board 聚合超时(秒,默认 agent 8 / flask 10 / board 45 # 监控区:hub 后台每 N 秒聚合一次,浏览器经 SSE 收版本号再拉快照(默认 5 秒
# HUB_BOARD_POLL_INTERVAL=5
# 单次聚合超时(秒,默认 agent 8 / flask 10 / board 45
# HUB_AGENT_TIMEOUT=8 # HUB_AGENT_TIMEOUT=8
# HUB_FLASK_TIMEOUT=10 # HUB_FLASK_TIMEOUT=10
# HUB_BOARD_TIMEOUT=45 # HUB_BOARD_TIMEOUT=45
+77 -25
View File
@@ -7,6 +7,7 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import sys import sys
from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
_REPO_ROOT = Path(__file__).resolve().parent.parent _REPO_ROOT = Path(__file__).resolve().parent.parent
@@ -51,6 +52,8 @@ from hub_sso import HUB_SSO_TTL_SEC, mint_hub_sso_token, safe_next_path
from url_public import browser_url, default_review_url, public_origin from url_public import browser_url, default_review_url, public_origin
from urllib.parse import urlencode from urllib.parse import urlencode
from hub_board_cache import HUB_BOARD_POLL_INTERVAL, board_store
try: try:
from exchange_orders import symbols_match as _symbols_match from exchange_orders import symbols_match as _symbols_match
except ImportError: except ImportError:
@@ -69,7 +72,7 @@ _allow_pub_raw = (os.getenv("HUB_ALLOW_PUBLIC") or "").strip().lower()
# 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护 # 云服务器 + 域名反代时设为 true:不做 IP 限制,仅靠 HUB_PASSWORD / 登录页保护
HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on") HUB_ALLOW_PUBLIC = _allow_pub_raw in ("1", "true", "yes", "on")
DIR = Path(__file__).resolve().parent DIR = Path(__file__).resolve().parent
HUB_BUILD = "20260528-hub-market" HUB_BUILD = "20260603-hub-board-sse"
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8")) HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45")) HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
@@ -133,7 +136,37 @@ def _find_exchange(ex_id: str) -> dict | None:
return None return None
app = FastAPI(title="复盘系统中控", docs_url=None, redoc_url=None) async def _run_board_aggregate() -> dict:
try:
body = await asyncio.wait_for(_build_monitor_board_payload(), timeout=HUB_BOARD_TIMEOUT)
return {"ok": True, **body}
except asyncio.TimeoutError:
return {
"ok": False,
"rows": [],
"error": "board_timeout",
"msg": (
f"监控聚合超过 {int(HUB_BOARD_TIMEOUT)} 秒。"
"请检查子代理/Flask,或设 HUB_BOARD_KEY_PRICES=false、缩短 HUB_FLASK_TIMEOUT"
),
"updated_at": __import__("datetime").datetime.now().isoformat(timespec="seconds"),
}
def _schedule_board_refresh() -> None:
board_store.request_refresh()
@asynccontextmanager
async def _hub_lifespan(_app: FastAPI):
await board_store.start(_run_board_aggregate)
try:
yield
finally:
await board_store.stop()
app = FastAPI(title="复盘系统中控", docs_url=None, redoc_url=None, lifespan=_hub_lifespan)
STATIC_DIR = DIR / "static" STATIC_DIR = DIR / "static"
if STATIC_DIR.is_dir(): if STATIC_DIR.is_dir():
app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets") app.mount("/assets", StaticFiles(directory=str(STATIC_DIR)), name="assets")
@@ -768,27 +801,33 @@ async def _build_monitor_board_payload() -> dict:
@app.get("/api/monitor/board") @app.get("/api/monitor/board")
async def api_monitor_board(): @app.get("/api/monitor/board/snapshot")
try: async def api_monitor_board_snapshot():
return await asyncio.wait_for(_build_monitor_board_payload(), timeout=HUB_BOARD_TIMEOUT) """读后台缓存快照;完整聚合由 hub 每 HUB_BOARD_POLL_INTERVAL 秒执行。"""
except asyncio.TimeoutError: return board_store.snapshot_dict()
return JSONResponse(
{
"ok": False, @app.get("/api/monitor/board/stream")
"rows": [], async def api_monitor_board_stream():
"error": "board_timeout", from fastapi.responses import StreamingResponse
"msg": (
f"监控聚合超过 {int(HUB_BOARD_TIMEOUT)} 秒。" return StreamingResponse(
"请检查子代理/Flask,或设 HUB_BOARD_KEY_PRICES=false、缩短 HUB_FLASK_TIMEOUT" board_store.iter_sse(),
), media_type="text/event-stream",
"updated_at": __import__("datetime").datetime.now().isoformat( headers={
timespec="seconds" "Cache-Control": "no-cache",
), "Connection": "keep-alive",
"X-Accel-Buffering": "no",
}, },
status_code=504,
) )
@app.post("/api/monitor/board/refresh")
async def api_monitor_board_refresh():
_schedule_board_refresh()
return {"ok": True, "board_version": board_store.version}
def _require_hub_logged_in(request: Request) -> None: def _require_hub_logged_in(request: Request) -> None:
if password_required() and not validate_session_token(request.cookies.get(SESSION_COOKIE)): if password_required() and not validate_session_token(request.cookies.get(SESSION_COOKIE)):
raise HTTPException(status_code=401, detail="未登录中控") raise HTTPException(status_code=401, detail="未登录中控")
@@ -877,12 +916,14 @@ async def api_cancel_order(exchange_id: str, body: CancelOrderBody):
payload = r.json() payload = r.json()
except Exception: except Exception:
payload = {"raw": (r.text or "")[:2000]} payload = {"raw": (r.text or "")[:2000]}
return { out = {
"exchange": ex, "exchange": ex,
"status_code": r.status_code, "status_code": r.status_code,
"payload": payload, "payload": payload,
"ok": bool(isinstance(payload, dict) and payload.get("ok")), "ok": bool(isinstance(payload, dict) and payload.get("ok")),
} }
_schedule_board_refresh()
return out
@app.post("/api/orders/{exchange_id}/cancel-symbol") @app.post("/api/orders/{exchange_id}/cancel-symbol")
@@ -902,12 +943,14 @@ async def api_cancel_symbol_orders(exchange_id: str, body: CancelSymbolOrdersBod
payload = r.json() payload = r.json()
except Exception: except Exception:
payload = {"raw": (r.text or "")[:2000]} payload = {"raw": (r.text or "")[:2000]}
return { out = {
"exchange": ex, "exchange": ex,
"status_code": r.status_code, "status_code": r.status_code,
"payload": payload, "payload": payload,
"ok": bool(isinstance(payload, dict) and payload.get("ok")), "ok": bool(isinstance(payload, dict) and payload.get("ok")),
} }
_schedule_board_refresh()
return out
@app.post("/api/close/{exchange_id}/position") @app.post("/api/close/{exchange_id}/position")
@@ -933,12 +976,14 @@ async def api_close_position(exchange_id: str, body: ClosePositionBody):
payload = r.json() payload = r.json()
except Exception: except Exception:
payload = {"raw": (r.text or "")[:2000]} payload = {"raw": (r.text or "")[:2000]}
return { out = {
"exchange": ex, "exchange": ex,
"status_code": r.status_code, "status_code": r.status_code,
"payload": payload, "payload": payload,
"ok": bool(isinstance(payload, dict) and payload.get("ok")), "ok": bool(isinstance(payload, dict) and payload.get("ok")),
} }
_schedule_board_refresh()
return out
@app.post("/api/orders/{exchange_id}/place-tpsl") @app.post("/api/orders/{exchange_id}/place-tpsl")
@@ -964,12 +1009,14 @@ async def api_place_tpsl(exchange_id: str, body: PlaceTpslBody):
payload = r.json() payload = r.json()
except Exception: except Exception:
payload = {"raw": (r.text or "")[:2000]} payload = {"raw": (r.text or "")[:2000]}
return { out = {
"exchange": ex, "exchange": ex,
"status_code": r.status_code, "status_code": r.status_code,
"payload": payload, "payload": payload,
"ok": bool(isinstance(payload, dict) and payload.get("ok")), "ok": bool(isinstance(payload, dict) and payload.get("ok")),
} }
_schedule_board_refresh()
return out
@app.post("/api/close/{exchange_id}") @app.post("/api/close/{exchange_id}")
@@ -984,7 +1031,9 @@ async def api_close_exchange(exchange_id: str):
body = r.json() body = r.json()
except Exception: except Exception:
body = {"raw": (r.text or "")[:2000]} body = {"raw": (r.text or "")[:2000]}
return {"exchange": ex, "status_code": r.status_code, "payload": body} out = {"exchange": ex, "status_code": r.status_code, "payload": body}
_schedule_board_refresh()
return out
@app.post("/api/close-all") @app.post("/api/close-all")
@@ -1007,6 +1056,7 @@ async def api_close_all(body: CloseAllBody | None = Body(default=None)):
return {"id": ex["id"], "name": ex["name"], "status_code": None, "error": str(e)} return {"id": ex["id"], "name": ex["name"], "status_code": None, "error": str(e)}
results = await asyncio.gather(*[one(ex) for ex in targets]) results = await asyncio.gather(*[one(ex) for ex in targets])
_schedule_board_refresh()
return {"results": list(results)} return {"results": list(results)}
@@ -1035,7 +1085,9 @@ def api_ping():
"service": "manual-trading-hub", "service": "manual-trading-hub",
"build": HUB_BUILD, "build": HUB_BUILD,
"trade_ui": False, "trade_ui": False,
"features": ["monitor", "settings", "auth"], "features": ["monitor", "settings", "auth", "board_sse"],
"board_poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
"board_version": board_store.version,
"password_required": password_required(), "password_required": password_required(),
"env_disabled_ids": sorted(env_force_disabled_ids()), "env_disabled_ids": sorted(env_force_disabled_ids()),
"hub_disabled_ids_raw": (os.getenv("HUB_DISABLED_IDS") or ""), "hub_disabled_ids_raw": (os.getenv("HUB_DISABLED_IDS") or ""),
+164
View File
@@ -0,0 +1,164 @@
"""监控区 board:后台定时聚合、内存快照、SSE 版本通知。"""
from __future__ import annotations
import asyncio
import json
import os
from collections.abc import AsyncIterator, Awaitable, Callable
from typing import Any
HUB_BOARD_POLL_INTERVAL = float(os.getenv("HUB_BOARD_POLL_INTERVAL", "5"))
HUB_BOARD_SSE_HEARTBEAT_SEC = float(os.getenv("HUB_BOARD_SSE_HEARTBEAT_SEC", "25"))
BuildFn = Callable[[], Awaitable[dict[str, Any]]]
class MonitorBoardStore:
def __init__(self) -> None:
self._lock = asyncio.Lock()
self.version = 0
self.payload: dict[str, Any] | None = None
self.aggregating = False
self.last_error: str | None = None
self._subscribers: list[asyncio.Queue[str | None]] = []
self._task: asyncio.Task | None = None
self._stop = asyncio.Event()
self._refresh = asyncio.Event()
self._build_fn: BuildFn | None = None
async def start(self, build_fn: BuildFn) -> None:
if self._task and not self._task.done():
return
self._build_fn = build_fn
self._stop.clear()
self._task = asyncio.create_task(self._loop(), name="hub-board-poll")
async def stop(self) -> None:
self._stop.set()
self._refresh.set()
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
self._broadcast(close=True)
def request_refresh(self) -> None:
self._refresh.set()
def snapshot_dict(self) -> dict[str, Any]:
p = self.payload or {}
rows = p.get("rows")
if not isinstance(rows, list):
rows = []
return {
"ok": p.get("ok", True) if self.payload else False,
"board_version": self.version,
"rows": rows,
"updated_at": p.get("updated_at"),
"aggregating": self.aggregating,
"error": self.last_error or p.get("error"),
"msg": p.get("msg"),
"poll_interval_sec": HUB_BOARD_POLL_INTERVAL,
}
def event_dict(self) -> dict[str, Any]:
p = self.payload or {}
return {
"board_version": self.version,
"updated_at": p.get("updated_at"),
"aggregating": self.aggregating,
"ok": p.get("ok", True) if self.payload else False,
"error": self.last_error or p.get("error"),
}
async def _loop(self) -> None:
assert self._build_fn is not None
while not self._stop.is_set():
await self._aggregate_once(self._build_fn)
if self._stop.is_set():
break
self._refresh.clear()
sleep_task = asyncio.create_task(asyncio.sleep(HUB_BOARD_POLL_INTERVAL))
refresh_task = asyncio.create_task(self._refresh.wait())
done, pending = await asyncio.wait(
{sleep_task, refresh_task},
return_when=asyncio.FIRST_COMPLETED,
)
for t in pending:
t.cancel()
async def _aggregate_once(self, build_fn: BuildFn) -> None:
async with self._lock:
self.aggregating = True
self._broadcast()
try:
result = await build_fn()
if not isinstance(result, dict):
result = {"ok": False, "msg": "聚合返回无效", "rows": []}
except Exception as e:
result = {"ok": False, "msg": str(e), "rows": [], "error": "aggregate_failed"}
async with self._lock:
self.version += 1
prev_rows = (self.payload or {}).get("rows") if isinstance(self.payload, dict) else None
if result.get("ok") is False and isinstance(prev_rows, list) and prev_rows:
result = {**result, "rows": prev_rows}
self.payload = result
self.last_error = None if result.get("ok") is not False else (
str(result.get("msg") or result.get("error") or "aggregate_failed")
)
self.aggregating = False
self._broadcast()
def _broadcast(self, *, close: bool = False) -> None:
dead: list[asyncio.Queue[str | None]] = []
for q in self._subscribers:
try:
q.put_nowait(None if close else json.dumps(self.event_dict(), ensure_ascii=False))
except asyncio.QueueFull:
try:
q.get_nowait()
except asyncio.QueueEmpty:
pass
try:
q.put_nowait(json.dumps(self.event_dict(), ensure_ascii=False))
except asyncio.QueueFull:
dead.append(q)
except Exception:
dead.append(q)
for q in dead:
if q in self._subscribers:
self._subscribers.remove(q)
async def iter_sse(self) -> AsyncIterator[str]:
q: asyncio.Queue[str | None] = asyncio.Queue(maxsize=32)
self._subscribers.append(q)
try:
yield _sse_frame(self.event_dict())
while True:
try:
raw = await asyncio.wait_for(q.get(), timeout=HUB_BOARD_SSE_HEARTBEAT_SEC)
except asyncio.TimeoutError:
yield ": heartbeat\n\n"
continue
if raw is None:
break
try:
data = json.loads(raw)
except Exception:
data = self.event_dict()
yield _sse_frame(data)
finally:
if q in self._subscribers:
self._subscribers.remove(q)
def _sse_frame(data: dict[str, Any]) -> str:
body = json.dumps(data, ensure_ascii=False)
return f"event: board\ndata: {body}\n\n"
board_store = MonitorBoardStore()
+117 -46
View File
@@ -1,17 +1,20 @@
(function () { (function () {
const toast = document.getElementById("toast"); const toast = document.getElementById("toast");
let settingsCache = null; let settingsCache = null;
let monitorTimer = null;
let authState = { required: false, logged_in: true }; let authState = { required: false, logged_in: true };
let tpslPending = null; let tpslPending = null;
let lastMonitorRows = []; let lastMonitorRows = [];
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || ""; let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1"; const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1";
const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000; const HUB_MONITOR_CACHE_MAX_AGE_MS = 6 * 60 * 60 * 1000;
const HUB_MONITOR_FETCH_TIMEOUT_MS = 55000; const MONITOR_BOARD_SNAPSHOT_URL = "/api/monitor/board/snapshot";
const HUB_MONITOR_SNAPSHOT_TIMEOUT_MS = 15000;
let lastMonitorBoardUpdatedAt = ""; let lastMonitorBoardUpdatedAt = "";
let localBoardVersion = 0;
let monitorBoardInFlight = false; let monitorBoardInFlight = false;
let monitorBoardSlowHintTimer = null; let monitorBoardSlowHintTimer = null;
let boardEventSource = null;
let sseReconnectTimer = null;
async function apiFetch(url, opts) { async function apiFetch(url, opts) {
const r = await fetch(url, opts); const r = await fetch(url, opts);
@@ -346,9 +349,52 @@
} }
function stopMonitorPoll() { function stopMonitorPoll() {
clearTimeout(monitorTimer); closeMonitorBoardStream();
clearInterval(monitorTimer); if (sseReconnectTimer) {
monitorTimer = null; clearTimeout(sseReconnectTimer);
sseReconnectTimer = null;
}
}
function closeMonitorBoardStream() {
if (boardEventSource) {
boardEventSource.close();
boardEventSource = null;
}
}
function connectMonitorBoardStream() {
closeMonitorBoardStream();
if (!document.getElementById("auto-monitor")?.checked) return;
if (currentPage() !== "monitor") return;
boardEventSource = new EventSource("/api/monitor/board/stream");
boardEventSource.addEventListener("board", (ev) => {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.board_version) || 0;
if (ver > localBoardVersion) {
void fetchMonitorBoardSnapshot({ background: true });
} else if (st.aggregating && lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, st.updated_at || lastMonitorBoardUpdatedAt, {
stale: true,
});
}
} catch (_) {}
});
boardEventSource.onerror = () => {
closeMonitorBoardStream();
if (sseReconnectTimer) clearTimeout(sseReconnectTimer);
sseReconnectTimer = setTimeout(() => {
if (currentPage() === "monitor" && document.getElementById("auto-monitor")?.checked) {
connectMonitorBoardStream();
void fetchMonitorBoardSnapshot({ background: true });
}
}, 8000);
};
}
async function requestMonitorBoardRefresh() {
await apiFetch("/api/monitor/board/refresh", { method: "POST" });
} }
function clearMonitorBoardSlowHint() { function clearMonitorBoardSlowHint() {
@@ -368,17 +414,18 @@
const sub = el.querySelector(".board-loading-sub"); const sub = el.querySelector(".board-loading-sub");
if (sub) { if (sub) {
sub.textContent = sub.textContent =
"聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速;下方超时后会提示错误。"; "后台首次聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速。";
} }
}, 12000); }, 12000);
} }
function saveMonitorBoardCache(rows, updatedAt) { function saveMonitorBoardCache(rows, updatedAt, boardVersion) {
try { try {
sessionStorage.setItem( sessionStorage.setItem(
HUB_MONITOR_BOARD_CACHE_KEY, HUB_MONITOR_BOARD_CACHE_KEY,
JSON.stringify({ JSON.stringify({
version: 1, version: 1,
board_version: boardVersion != null ? boardVersion : localBoardVersion,
updated_at: updatedAt || "", updated_at: updatedAt || "",
rows: rows || [], rows: rows || [],
saved_at: Date.now(), saved_at: Date.now(),
@@ -409,6 +456,7 @@
if (!cached) return false; if (!cached) return false;
lastMonitorRows = cached.rows; lastMonitorRows = cached.rows;
lastMonitorBoardUpdatedAt = cached.updated_at || ""; lastMonitorBoardUpdatedAt = cached.updated_at || "";
localBoardVersion = Number(cached.board_version) || 0;
applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true }); applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true });
return true; return true;
} }
@@ -430,8 +478,8 @@
const ts = tsRaw.replace("T", " "); const ts = tsRaw.replace("T", " ");
upd.textContent = options.stale upd.textContent = options.stale
? ts ? ts
? `缓存 ${ts} · 刷新中…` ? `缓存 ${ts} · 后台聚合中…`
: "刷新中…" : "后台聚合中…"
: ts : ts
? `UPD ${ts}` ? `UPD ${ts}`
: ""; : "";
@@ -439,22 +487,10 @@
renderMonitorGrid(rows || []); renderMonitorGrid(rows || []);
} }
function scheduleNextMonitorPoll() {
stopMonitorPoll();
if (!document.getElementById("auto-monitor")?.checked) return;
if (currentPage() !== "monitor") return;
monitorTimer = setTimeout(async () => {
await loadMonitorBoard({ background: true });
scheduleNextMonitorPoll();
}, 5000);
}
function startMonitorPoll() { function startMonitorPoll() {
stopMonitorPoll();
const hadCache = restoreMonitorBoardFromCache(); const hadCache = restoreMonitorBoardFromCache();
void loadMonitorBoard({ background: hadCache }).finally(() => { void fetchMonitorBoardSnapshot({ showLoading: !hadCache });
scheduleNextMonitorPoll(); connectMonitorBoardStream();
});
} }
async function loadSettings() { async function loadSettings() {
@@ -607,39 +643,58 @@
return `<span class="pos-breakeven-badge">已保本</span>`; return `<span class="pos-breakeven-badge">已保本</span>`;
} }
async function loadMonitorBoard(opts) { async function fetchMonitorBoardSnapshot(opts) {
const options = opts || {}; const options = opts || {};
const background = !!options.background; const background = !!options.background;
const force = !!options.force; const showLoading = !!options.showLoading && !lastMonitorRows.length;
if (monitorBoardInFlight && background && !force) return;
const box = document.getElementById("monitor-grid"); const box = document.getElementById("monitor-grid");
const showLoading = !background && !lastMonitorRows.length; if (monitorBoardInFlight && background) return;
if (showLoading && box) { if (showLoading && box) {
box.innerHTML = box.innerHTML =
'<div class="board-loading"><span class="board-loading-spin" aria-hidden="true"></span>正在聚合四所数据…<p class="board-loading-sub"></p></div>'; '<div class="board-loading"><span class="board-loading-spin" aria-hidden="true"></span>正在加载监控快照…<p class="board-loading-sub"></p></div>';
scheduleMonitorBoardSlowHint(box); scheduleMonitorBoardSlowHint(box);
} else if (background && lastMonitorRows.length) { } else if (background && lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, null, { stale: true }); applyMonitorBoardUi(lastMonitorRows, null, { stale: true });
} }
monitorBoardInFlight = true; monitorBoardInFlight = true;
const ctrl = new AbortController(); const ctrl = new AbortController();
const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_FETCH_TIMEOUT_MS); const fetchTimer = setTimeout(() => ctrl.abort(), HUB_MONITOR_SNAPSHOT_TIMEOUT_MS);
try { try {
const r = await apiFetch("/api/monitor/board", { signal: ctrl.signal }); const r = await apiFetch(MONITOR_BOARD_SNAPSHOT_URL, { signal: ctrl.signal });
const data = await r.json(); const data = await r.json();
if (!r.ok) { if (!r.ok) {
throw new Error(data.msg || data.detail || `HTTP ${r.status}`); throw new Error(data.msg || data.detail || `HTTP ${r.status}`);
} }
lastMonitorRows = data.rows || []; const ver = Number(data.board_version) || 0;
saveMonitorBoardCache(lastMonitorRows, data.updated_at); const rows = data.rows || [];
applyMonitorBoardUi(lastMonitorRows, data.updated_at, { stale: false }); const waitingFirst = data.aggregating && !rows.length && ver <= localBoardVersion;
if (waitingFirst && showLoading) {
if (box) {
const sub = box.querySelector(".board-loading-sub");
if (sub) sub.textContent = "后台正在首次聚合四所数据(约 5~15 秒)…";
}
return;
}
if (ver >= localBoardVersion || !lastMonitorRows.length) {
localBoardVersion = ver;
lastMonitorRows = rows;
saveMonitorBoardCache(lastMonitorRows, data.updated_at, ver);
applyMonitorBoardUi(lastMonitorRows, data.updated_at, {
stale: !!data.aggregating,
});
} else if (data.aggregating && lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, data.updated_at || lastMonitorBoardUpdatedAt, {
stale: true,
});
}
if (data.ok === false && data.msg && !background) {
showToast(String(data.msg), true);
}
} catch (e) { } catch (e) {
const msg = const msg =
e && e.name === "AbortError" e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e);
? "聚合超时(约 55 秒)。请检查子代理/Flask 是否运行,或关闭 HUB_BOARD_KEY_PRICES 加速"
: String(e);
if (background && lastMonitorRows.length) { if (background && lastMonitorRows.length) {
showToast("监控数据刷新失败,仍显示上次缓存", true); showToast("快照读取失败,仍显示上次数据", true);
applyMonitorBoardUi(lastMonitorRows, null, { stale: false }); applyMonitorBoardUi(lastMonitorRows, null, { stale: false });
return; return;
} }
@@ -651,6 +706,17 @@
} }
} }
async function refreshMonitorBoardNow() {
if (lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, lastMonitorBoardUpdatedAt, { stale: true });
}
try {
await requestMonitorBoardRefresh();
} catch (e) {
showToast(String(e), true);
}
}
function closeExchangeFullscreen() { function closeExchangeFullscreen() {
expandedExchangeId = ""; expandedExchangeId = "";
sessionStorage.removeItem("hub_expanded_ex"); sessionStorage.removeItem("hub_expanded_ex");
@@ -1575,7 +1641,7 @@
); );
if (ok) { if (ok) {
closeTpslModal(); closeTpslModal();
loadMonitorBoard(); refreshMonitorBoardNow();
} }
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
@@ -1654,7 +1720,7 @@
const pl = j.payload || {}; const pl = j.payload || {};
const ok = j.ok && pl.ok !== false; const ok = j.ok && pl.ok !== false;
showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok); showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok);
loadMonitorBoard(); refreshMonitorBoardNow();
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
} }
@@ -1677,7 +1743,7 @@
const ok = j.ok && pl.ok !== false; const ok = j.ok && pl.ok !== false;
const n = pl.cancelled_count != null ? pl.cancelled_count : "?"; const n = pl.cancelled_count != null ? pl.cancelled_count : "?";
showToast(ok ? `已撤销 ${n}` : pl.error || JSON.stringify(j), !ok); showToast(ok ? `已撤销 ${n}` : pl.error || JSON.stringify(j), !ok);
loadMonitorBoard(); refreshMonitorBoardNow();
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
} }
@@ -1756,7 +1822,7 @@
? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}` ? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}`
: pl.error) || JSON.stringify(j, null, 2); : pl.error) || JSON.stringify(j, null, 2);
showToast(msg, !ok); showToast(msg, !ok);
loadMonitorBoard(); refreshMonitorBoardNow();
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
} }
@@ -1768,7 +1834,7 @@
const r = await apiFetch("/api/close/" + encodeURIComponent(id), { method: "POST" }); const r = await apiFetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
const j = await r.json(); const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok); showToast(JSON.stringify(j, null, 2), !r.ok);
loadMonitorBoard(); refreshMonitorBoardNow();
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
} }
@@ -1785,7 +1851,7 @@
}); });
const j = await r.json(); const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok); showToast(JSON.stringify(j, null, 2), !r.ok);
loadMonitorBoard(); refreshMonitorBoardNow();
} catch (e) { } catch (e) {
showToast(String(e), true); showToast(String(e), true);
} }
@@ -1918,9 +1984,14 @@
location.href = "/login"; location.href = "/login";
}; };
document.getElementById("btn-monitor-refresh").onclick = () => document.getElementById("btn-monitor-refresh").onclick = () => refreshMonitorBoardNow();
loadMonitorBoard({ force: true, background: !!lastMonitorRows.length }); document.getElementById("auto-monitor").onchange = () => {
document.getElementById("auto-monitor").onchange = startMonitorPoll; if (document.getElementById("auto-monitor").checked) {
connectMonitorBoardStream();
} else {
closeMonitorBoardStream();
}
};
document.getElementById("btn-close-all").onclick = closeAll; document.getElementById("btn-close-all").onclick = closeAll;
document.getElementById("btn-settings-save").onclick = saveSettings; document.getElementById("btn-settings-save").onclick = saveSettings;
document.getElementById("btn-settings-reload").onclick = loadSettingsUI; document.getElementById("btn-settings-reload").onclick = loadSettingsUI;
+2 -2
View File
@@ -48,7 +48,7 @@
<div class="toolbar"> <div class="toolbar">
<button type="button" id="btn-monitor-refresh" class="primary">立即刷新</button> <button type="button" id="btn-monitor-refresh" class="primary">立即刷新</button>
<label class="chk-label"> <label class="chk-label">
<input type="checkbox" id="auto-monitor" checked /> 每 5 秒自动 <input type="checkbox" id="auto-monitor" checked /> 后台每 5 秒聚合 · SSE 自动
</label> </label>
<button type="button" id="btn-close-all" class="danger">全局紧急全平</button> <button type="button" id="btn-close-all" class="danger">全局紧急全平</button>
<span class="toolbar-spacer"></span> <span class="toolbar-spacer"></span>
@@ -245,6 +245,6 @@
<div id="toast"></div> <div id="toast"></div>
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script> <script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
<script src="/assets/chart.js?v=20260528-hub-no-30m"></script> <script src="/assets/chart.js?v=20260528-hub-no-30m"></script>
<script src="/assets/app.js?v=20260603-hub-board-poll-fix"></script> <script src="/assets/app.js?v=20260603-hub-board-sse"></script>
</body> </body>
</html> </html>
+44
View File
@@ -0,0 +1,44 @@
"""后台 board 缓存:版本递增与快照。"""
from __future__ import annotations
import asyncio
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
sys.path.insert(0, str(ROOT / "manual_trading_hub"))
from hub_board_cache import MonitorBoardStore # noqa: E402
class TestHubBoardStore(unittest.TestCase):
def test_snapshot_and_version(self) -> None:
store = MonitorBoardStore()
store.version = 2
store.payload = {"ok": True, "rows": [{"id": "0"}], "updated_at": "2026-01-01T00:00:00"}
snap = store.snapshot_dict()
self.assertEqual(snap["board_version"], 2)
self.assertEqual(len(snap["rows"]), 1)
def test_aggregate_increments_version(self) -> None:
async def run() -> None:
store = MonitorBoardStore()
n = 0
async def build():
nonlocal n
n += 1
return {"ok": True, "rows": [{"n": n}], "updated_at": "t"}
await store.start(build)
await asyncio.sleep(0.05)
self.assertGreaterEqual(store.version, 1)
await store.stop()
asyncio.run(run())
if __name__ == "__main__":
unittest.main()