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
+117 -46
View File
@@ -1,17 +1,20 @@
(function () {
const toast = document.getElementById("toast");
let settingsCache = null;
let monitorTimer = null;
let authState = { required: false, logged_in: true };
let tpslPending = null;
let lastMonitorRows = [];
let expandedExchangeId = sessionStorage.getItem("hub_expanded_ex") || "";
const HUB_MONITOR_BOARD_CACHE_KEY = "hub_monitor_board_v1";
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 localBoardVersion = 0;
let monitorBoardInFlight = false;
let monitorBoardSlowHintTimer = null;
let boardEventSource = null;
let sseReconnectTimer = null;
async function apiFetch(url, opts) {
const r = await fetch(url, opts);
@@ -346,9 +349,52 @@
}
function stopMonitorPoll() {
clearTimeout(monitorTimer);
clearInterval(monitorTimer);
monitorTimer = null;
closeMonitorBoardStream();
if (sseReconnectTimer) {
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() {
@@ -368,17 +414,18 @@
const sub = el.querySelector(".board-loading-sub");
if (sub) {
sub.textContent =
"聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速;下方超时后会提示错误。";
"后台首次聚合较慢(四所子代理 + Flask)。可检查 PM2、或设 HUB_BOARD_KEY_PRICES=false 加速。";
}
}, 12000);
}
function saveMonitorBoardCache(rows, updatedAt) {
function saveMonitorBoardCache(rows, updatedAt, boardVersion) {
try {
sessionStorage.setItem(
HUB_MONITOR_BOARD_CACHE_KEY,
JSON.stringify({
version: 1,
board_version: boardVersion != null ? boardVersion : localBoardVersion,
updated_at: updatedAt || "",
rows: rows || [],
saved_at: Date.now(),
@@ -409,6 +456,7 @@
if (!cached) return false;
lastMonitorRows = cached.rows;
lastMonitorBoardUpdatedAt = cached.updated_at || "";
localBoardVersion = Number(cached.board_version) || 0;
applyMonitorBoardUi(cached.rows, lastMonitorBoardUpdatedAt, { stale: true });
return true;
}
@@ -430,8 +478,8 @@
const ts = tsRaw.replace("T", " ");
upd.textContent = options.stale
? ts
? `缓存 ${ts} · 刷新中…`
: "刷新中…"
? `缓存 ${ts} · 后台聚合中…`
: "后台聚合中…"
: ts
? `UPD ${ts}`
: "";
@@ -439,22 +487,10 @@
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() {
stopMonitorPoll();
const hadCache = restoreMonitorBoardFromCache();
void loadMonitorBoard({ background: hadCache }).finally(() => {
scheduleNextMonitorPoll();
});
void fetchMonitorBoardSnapshot({ showLoading: !hadCache });
connectMonitorBoardStream();
}
async function loadSettings() {
@@ -607,39 +643,58 @@
return `<span class="pos-breakeven-badge">已保本</span>`;
}
async function loadMonitorBoard(opts) {
async function fetchMonitorBoardSnapshot(opts) {
const options = opts || {};
const background = !!options.background;
const force = !!options.force;
if (monitorBoardInFlight && background && !force) return;
const showLoading = !!options.showLoading && !lastMonitorRows.length;
const box = document.getElementById("monitor-grid");
const showLoading = !background && !lastMonitorRows.length;
if (monitorBoardInFlight && background) return;
if (showLoading && box) {
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);
} else if (background && lastMonitorRows.length) {
applyMonitorBoardUi(lastMonitorRows, null, { stale: true });
}
monitorBoardInFlight = true;
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 {
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();
if (!r.ok) {
throw new Error(data.msg || data.detail || `HTTP ${r.status}`);
}
lastMonitorRows = data.rows || [];
saveMonitorBoardCache(lastMonitorRows, data.updated_at);
applyMonitorBoardUi(lastMonitorRows, data.updated_at, { stale: false });
const ver = Number(data.board_version) || 0;
const rows = data.rows || [];
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) {
const msg =
e && e.name === "AbortError"
? "聚合超时(约 55 秒)。请检查子代理/Flask 是否运行,或关闭 HUB_BOARD_KEY_PRICES 加速"
: String(e);
e && e.name === "AbortError" ? "读取监控快照超时,请检查中控是否运行" : String(e);
if (background && lastMonitorRows.length) {
showToast("监控数据刷新失败,仍显示上次缓存", true);
showToast("快照读取失败,仍显示上次数据", true);
applyMonitorBoardUi(lastMonitorRows, null, { stale: false });
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() {
expandedExchangeId = "";
sessionStorage.removeItem("hub_expanded_ex");
@@ -1575,7 +1641,7 @@
);
if (ok) {
closeTpslModal();
loadMonitorBoard();
refreshMonitorBoardNow();
}
} catch (e) {
showToast(String(e), true);
@@ -1654,7 +1720,7 @@
const pl = j.payload || {};
const ok = j.ok && pl.ok !== false;
showToast(ok ? "已撤单" : pl.error || JSON.stringify(j), !ok);
loadMonitorBoard();
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
@@ -1677,7 +1743,7 @@
const ok = j.ok && pl.ok !== false;
const n = pl.cancelled_count != null ? pl.cancelled_count : "?";
showToast(ok ? `已撤销 ${n}` : pl.error || JSON.stringify(j), !ok);
loadMonitorBoard();
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
@@ -1756,7 +1822,7 @@
? `已平仓 ${pl.closed.symbol} ${pl.closed.side} · 张数 ${pl.closed.amount}`
: pl.error) || JSON.stringify(j, null, 2);
showToast(msg, !ok);
loadMonitorBoard();
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
@@ -1768,7 +1834,7 @@
const r = await apiFetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok);
loadMonitorBoard();
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
@@ -1785,7 +1851,7 @@
});
const j = await r.json();
showToast(JSON.stringify(j, null, 2), !r.ok);
loadMonitorBoard();
refreshMonitorBoardNow();
} catch (e) {
showToast(String(e), true);
}
@@ -1918,9 +1984,14 @@
location.href = "/login";
};
document.getElementById("btn-monitor-refresh").onclick = () =>
loadMonitorBoard({ force: true, background: !!lastMonitorRows.length });
document.getElementById("auto-monitor").onchange = startMonitorPoll;
document.getElementById("btn-monitor-refresh").onclick = () => refreshMonitorBoardNow();
document.getElementById("auto-monitor").onchange = () => {
if (document.getElementById("auto-monitor").checked) {
connectMonitorBoardStream();
} else {
closeMonitorBoardStream();
}
};
document.getElementById("btn-close-all").onclick = closeAll;
document.getElementById("btn-settings-save").onclick = saveSettings;
document.getElementById("btn-settings-reload").onclick = loadSettingsUI;