中控总览改为后台常驻并行拉取热缓存,浏览器只读缓存并经 SSE 更新。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,8 @@ FLASK_SECRET_KEY=change-me-to-long-random
|
|||||||
DB_PATH=hub.db
|
DB_PATH=hub.db
|
||||||
HUB_PULL_INTERVAL_SEC=10
|
HUB_PULL_INTERVAL_SEC=10
|
||||||
HUB_PULL_TIMEOUT_SEC=5
|
HUB_PULL_TIMEOUT_SEC=5
|
||||||
|
# 总览 SSE 心跳(秒);浏览器只读缓存,靠 SSE 收版本更新
|
||||||
|
HUB_SSE_HEARTBEAT_SEC=25
|
||||||
|
|
||||||
# 中控企业微信(离线/故障告警,与客户端通知分开)
|
# 中控企业微信(离线/故障告警,与客户端通知分开)
|
||||||
HUB_WECHAT_ENABLED=false
|
HUB_WECHAT_ENABLED=false
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
@@ -19,6 +17,7 @@ from flask import (
|
|||||||
request,
|
request,
|
||||||
send_file,
|
send_file,
|
||||||
session,
|
session,
|
||||||
|
stream_with_context,
|
||||||
url_for,
|
url_for,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -62,6 +61,7 @@ from lib.instances_lib import (
|
|||||||
list_instances,
|
list_instances,
|
||||||
update_instance,
|
update_instance,
|
||||||
)
|
)
|
||||||
|
from lib.overview_cache_lib import overview_store
|
||||||
from lib.sso_lib import build_embed_open_url
|
from lib.sso_lib import build_embed_open_url
|
||||||
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
|
from lib.stats_agg_lib import build_stats_curve, build_stats_summary, normalize_window
|
||||||
from lib.wechat_lib import (
|
from lib.wechat_lib import (
|
||||||
@@ -486,38 +486,54 @@ def desk():
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _on_overview_pulled(rows: list) -> None:
|
||||||
|
"""常驻 pull 回调:健康巡检 + 余额采样(与浏览器是否打开无关)."""
|
||||||
|
try:
|
||||||
|
inspect_overview_rows(rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
record_cash_samples_from_overview(rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/overview")
|
@app.route("/api/overview")
|
||||||
@login_required
|
@login_required
|
||||||
def api_overview():
|
def api_overview():
|
||||||
rows = pull_all_enabled()
|
"""只读热缓存;不在请求内现场拉实例(后台常驻并行 pull)."""
|
||||||
return jsonify({"ok": True, "instances": rows})
|
force = (request.args.get("force") or "").strip().lower() in ("1", "true", "yes")
|
||||||
|
if force:
|
||||||
|
overview_store.request_refresh()
|
||||||
|
return jsonify(overview_store.snapshot_dict())
|
||||||
|
|
||||||
|
|
||||||
def _health_pull_interval_sec() -> float:
|
@app.route("/api/overview/refresh", methods=["POST"])
|
||||||
try:
|
@login_required
|
||||||
return max(5.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
|
def api_overview_refresh():
|
||||||
except (TypeError, ValueError):
|
overview_store.request_refresh()
|
||||||
return 10.0
|
return jsonify({"ok": True, "msg": "已触发后台刷新"})
|
||||||
|
|
||||||
|
|
||||||
def _health_watch_loop() -> None:
|
@app.route("/api/overview/stream")
|
||||||
"""后台巡检:即使无人打开总览也会告警;并采样余额曲线."""
|
@login_required
|
||||||
while True:
|
def api_overview_stream():
|
||||||
try:
|
return Response(
|
||||||
rows = pull_all_enabled()
|
stream_with_context(overview_store.iter_sse()),
|
||||||
inspect_overview_rows(rows)
|
mimetype="text/event-stream",
|
||||||
record_cash_samples_from_overview(rows)
|
headers={
|
||||||
except Exception:
|
"Cache-Control": "no-cache",
|
||||||
pass
|
"Connection": "keep-alive",
|
||||||
time.sleep(_health_pull_interval_sec())
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def start_health_watch() -> None:
|
def start_overview_cache() -> None:
|
||||||
t = threading.Thread(target=_health_watch_loop, name="hub-health-watch", daemon=True)
|
overview_store.start(pull_all_enabled, on_pull=_on_overview_pulled)
|
||||||
t.start()
|
|
||||||
|
|
||||||
|
|
||||||
start_health_watch()
|
start_overview_cache()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
start_hub_backup_scheduler(
|
start_hub_backup_scheduler(
|
||||||
|
|||||||
@@ -0,0 +1,197 @@
|
|||||||
|
"""中控总览热缓存:后台常驻并行 pull,与浏览器是否打开无关;SSE 仅扇出版本通知."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from collections.abc import Callable, Iterator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
OnPullFn = Callable[[list[dict[str, Any]]], None]
|
||||||
|
|
||||||
|
|
||||||
|
def _poll_interval_sec() -> float:
|
||||||
|
try:
|
||||||
|
return max(3.0, float(os.getenv("HUB_PULL_INTERVAL_SEC") or 10))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sse_heartbeat_sec() -> float:
|
||||||
|
try:
|
||||||
|
return max(10.0, float(os.getenv("HUB_SSE_HEARTBEAT_SEC") or 25))
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 25.0
|
||||||
|
|
||||||
|
|
||||||
|
class OverviewStore:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._lock = threading.RLock()
|
||||||
|
self.version = 0
|
||||||
|
self.instances: list[dict[str, Any]] = []
|
||||||
|
self.pulling = True # 启动至首轮完成前视为拉取中
|
||||||
|
self.last_error: str | None = None
|
||||||
|
self.updated_at: str = ""
|
||||||
|
self._ready = False
|
||||||
|
self._subscribers: list[queue.Queue[str | None]] = []
|
||||||
|
self._stop = threading.Event()
|
||||||
|
self._refresh = threading.Event()
|
||||||
|
self._thread: threading.Thread | None = None
|
||||||
|
self._pull_fn: Callable[[], list[dict[str, Any]]] | None = None
|
||||||
|
self._on_pull: OnPullFn | None = None
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
pull_fn: Callable[[], list[dict[str, Any]]],
|
||||||
|
*,
|
||||||
|
on_pull: OnPullFn | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._pull_fn = pull_fn
|
||||||
|
self._on_pull = on_pull
|
||||||
|
if self._thread and self._thread.is_alive():
|
||||||
|
return
|
||||||
|
self._stop.clear()
|
||||||
|
with self._lock:
|
||||||
|
self.pulling = True
|
||||||
|
self._ready = False
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._loop,
|
||||||
|
daemon=True,
|
||||||
|
name="hub-overview-pull",
|
||||||
|
)
|
||||||
|
self._thread.start()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._stop.set()
|
||||||
|
self._refresh.set()
|
||||||
|
self._broadcast(close=True)
|
||||||
|
|
||||||
|
def request_refresh(self) -> None:
|
||||||
|
self._refresh.set()
|
||||||
|
|
||||||
|
def snapshot_dict(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
rows = list(self.instances)
|
||||||
|
ver = self.version
|
||||||
|
pulling = self.pulling
|
||||||
|
err = self.last_error
|
||||||
|
updated_at = self.updated_at
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"instances": rows,
|
||||||
|
"overview_version": ver,
|
||||||
|
"pulling": pulling,
|
||||||
|
"updated_at": updated_at,
|
||||||
|
"error": err,
|
||||||
|
"poll_interval_sec": _poll_interval_sec(),
|
||||||
|
"from_cache": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
def event_dict(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
return {
|
||||||
|
"overview_version": self.version,
|
||||||
|
"updated_at": self.updated_at,
|
||||||
|
"pulling": self.pulling,
|
||||||
|
"ok": True,
|
||||||
|
"error": self.last_error,
|
||||||
|
"poll_interval_sec": _poll_interval_sec(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _loop(self) -> None:
|
||||||
|
assert self._pull_fn is not None
|
||||||
|
while not self._stop.is_set():
|
||||||
|
self._pull_once()
|
||||||
|
if self._stop.is_set():
|
||||||
|
break
|
||||||
|
self._refresh.clear()
|
||||||
|
self._refresh.wait(timeout=_poll_interval_sec())
|
||||||
|
|
||||||
|
def _pull_once(self) -> None:
|
||||||
|
assert self._pull_fn is not None
|
||||||
|
with self._lock:
|
||||||
|
self.pulling = True
|
||||||
|
self._broadcast()
|
||||||
|
try:
|
||||||
|
rows = self._pull_fn()
|
||||||
|
if not isinstance(rows, list):
|
||||||
|
rows = []
|
||||||
|
if self._on_pull is not None:
|
||||||
|
try:
|
||||||
|
self._on_pull(rows)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
with self._lock:
|
||||||
|
self.version += 1
|
||||||
|
self.instances = rows
|
||||||
|
self.updated_at = time.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
self.last_error = None
|
||||||
|
self.pulling = False
|
||||||
|
self._ready = True
|
||||||
|
except Exception as e:
|
||||||
|
with self._lock:
|
||||||
|
self.version += 1
|
||||||
|
self.last_error = str(e) or "pull_failed"
|
||||||
|
self.pulling = False
|
||||||
|
self._ready = True
|
||||||
|
# 保留上一轮成功缓存,避免整页空白
|
||||||
|
self._broadcast()
|
||||||
|
|
||||||
|
def _broadcast(self, *, close: bool = False) -> None:
|
||||||
|
with self._lock:
|
||||||
|
subs = list(self._subscribers)
|
||||||
|
event = None if close else json.dumps(self.event_dict(), ensure_ascii=False)
|
||||||
|
dead: list[queue.Queue[str | None]] = []
|
||||||
|
for q in subs:
|
||||||
|
try:
|
||||||
|
q.put_nowait(None if close else event)
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
q.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
q.put_nowait(event)
|
||||||
|
except queue.Full:
|
||||||
|
dead.append(q)
|
||||||
|
except Exception:
|
||||||
|
dead.append(q)
|
||||||
|
if dead:
|
||||||
|
with self._lock:
|
||||||
|
for q in dead:
|
||||||
|
if q in self._subscribers:
|
||||||
|
self._subscribers.remove(q)
|
||||||
|
|
||||||
|
def iter_sse(self) -> Iterator[str]:
|
||||||
|
q: queue.Queue[str | None] = queue.Queue(maxsize=32)
|
||||||
|
with self._lock:
|
||||||
|
self._subscribers.append(q)
|
||||||
|
try:
|
||||||
|
yield _sse_frame(self.event_dict())
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
raw = q.get(timeout=_sse_heartbeat_sec())
|
||||||
|
except queue.Empty:
|
||||||
|
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:
|
||||||
|
with self._lock:
|
||||||
|
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: overview\ndata: {body}\n\n"
|
||||||
|
|
||||||
|
|
||||||
|
overview_store = OverviewStore()
|
||||||
+88
-12
@@ -581,7 +581,7 @@
|
|||||||
if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张";
|
if (dd.submitted_sheets != null) msg += " · " + dd.submitted_sheets + " 张";
|
||||||
if (dd.fully_closed) msg += " · 已全平";
|
if (dd.fully_closed) msg += " · 已全平";
|
||||||
alert(msg);
|
alert(msg);
|
||||||
await refresh();
|
await requestOverviewRefresh();
|
||||||
} else {
|
} else {
|
||||||
alert((dd && dd.msg) || "平仓失败");
|
alert((dd && dd.msg) || "平仓失败");
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
@@ -759,21 +759,34 @@
|
|||||||
return best;
|
return best;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function refresh() {
|
let overviewVersion = 0;
|
||||||
|
let overviewLoading = false;
|
||||||
|
let overviewEventSource = null;
|
||||||
|
let overviewReconnectTimer = null;
|
||||||
|
|
||||||
|
async function refresh(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const silent = !!opts.silent;
|
||||||
const status = document.getElementById("overview-status");
|
const status = document.getElementById("overview-status");
|
||||||
const grid = document.getElementById("overview-grid");
|
const grid = document.getElementById("overview-grid");
|
||||||
if (!grid) return;
|
if (!grid) return;
|
||||||
if (status) status.textContent = "刷新中…";
|
if (overviewLoading) return;
|
||||||
|
overviewLoading = true;
|
||||||
|
if (status && !silent) status.textContent = "读取缓存…";
|
||||||
try {
|
try {
|
||||||
const r = await fetch("/api/overview");
|
// 只读中控热缓存;后台常驻并行 pull,不在此请求内打实例
|
||||||
|
const r = await fetch("/api/overview", { credentials: "same-origin" });
|
||||||
const d = await r.json();
|
const d = await r.json();
|
||||||
if (!d.ok) throw new Error(d.msg || "失败");
|
if (!d.ok) throw new Error(d.msg || d.error || "失败");
|
||||||
|
overviewVersion = Number(d.overview_version) || overviewVersion;
|
||||||
const list = sortInstancesByMode(d.instances || []);
|
const list = sortInstancesByMode(d.instances || []);
|
||||||
renderOverviewSummary(list);
|
renderOverviewSummary(list);
|
||||||
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list);
|
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint(list);
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
grid.innerHTML =
|
grid.innerHTML =
|
||||||
'<p class="muted">暂无启用实例,请先到「实例」添加.</p>';
|
d.pulling || !d.updated_at
|
||||||
|
? '<p class="muted">后台首次拉取中…</p>'
|
||||||
|
: '<p class="muted">暂无启用实例,请先到「实例」添加.</p>';
|
||||||
} else if (isHubPhone()) {
|
} else if (isHubPhone()) {
|
||||||
grid.innerHTML =
|
grid.innerHTML =
|
||||||
'<div class="hub-phone-cards">' + list.map(renderPhoneCard).join("") + "</div>";
|
'<div class="hub-phone-cards">' + list.map(renderPhoneCard).join("") + "</div>";
|
||||||
@@ -790,15 +803,20 @@
|
|||||||
}
|
}
|
||||||
if (status) {
|
if (status) {
|
||||||
const s = summarizeOverview(list);
|
const s = summarizeOverview(list);
|
||||||
const pulled = latestPulledAt(list);
|
const pulled = latestPulledAt(list) || d.updated_at || "";
|
||||||
|
const sec = Number(d.poll_interval_sec) || 10;
|
||||||
|
const live = overviewEventSource && overviewEventSource.readyState === 1;
|
||||||
status.textContent =
|
status.textContent =
|
||||||
"已更新 · 共 " +
|
(live ? "SSE 已连接 · " : "") +
|
||||||
|
"缓存 " +
|
||||||
list.length +
|
list.length +
|
||||||
" 台 · 在线 " +
|
" 台 · 在线 " +
|
||||||
s.all.online +
|
s.all.online +
|
||||||
" · 离线 " +
|
" · 离线 " +
|
||||||
s.all.offline +
|
s.all.offline +
|
||||||
(pulled ? " · 拉取时间 " + pulled : "");
|
(pulled ? " · " + pulled : "") +
|
||||||
|
(d.pulling ? " · 后台拉取中" : " · 后台每 " + sec + "s 并行拉") +
|
||||||
|
(d.error ? " · " + d.error : "");
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (status) status.textContent = e.message || "加载失败";
|
if (status) status.textContent = e.message || "加载失败";
|
||||||
@@ -808,9 +826,60 @@
|
|||||||
box.innerHTML = "";
|
box.innerHTML = "";
|
||||||
}
|
}
|
||||||
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint([]);
|
if (global.HubQuotes && HubQuotes.paint) HubQuotes.paint([]);
|
||||||
|
} finally {
|
||||||
|
overviewLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeOverviewStream() {
|
||||||
|
if (overviewEventSource) {
|
||||||
|
overviewEventSource.close();
|
||||||
|
overviewEventSource = null;
|
||||||
|
}
|
||||||
|
if (overviewReconnectTimer) {
|
||||||
|
clearTimeout(overviewReconnectTimer);
|
||||||
|
overviewReconnectTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectOverviewStream() {
|
||||||
|
const grid = document.getElementById("overview-grid");
|
||||||
|
if (!grid || typeof EventSource === "undefined") return;
|
||||||
|
closeOverviewStream();
|
||||||
|
overviewEventSource = new EventSource("/api/overview/stream");
|
||||||
|
overviewEventSource.addEventListener("overview", function (ev) {
|
||||||
|
try {
|
||||||
|
const st = JSON.parse(ev.data || "{}");
|
||||||
|
const ver = Number(st.overview_version) || 0;
|
||||||
|
if (ver && ver !== overviewVersion) {
|
||||||
|
refresh({ silent: true });
|
||||||
|
} else if (st.pulling) {
|
||||||
|
const status = document.getElementById("overview-status");
|
||||||
|
if (status) status.textContent = "后台并行拉取中…";
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
});
|
||||||
|
overviewEventSource.onerror = function () {
|
||||||
|
closeOverviewStream();
|
||||||
|
const status = document.getElementById("overview-status");
|
||||||
|
if (status) status.textContent = "SSE 断开,8s 后重连…";
|
||||||
|
overviewReconnectTimer = setTimeout(function () {
|
||||||
|
connectOverviewStream();
|
||||||
|
refresh({ silent: true });
|
||||||
|
}, 8000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestOverviewRefresh() {
|
||||||
|
try {
|
||||||
|
await fetch("/api/overview/refresh", {
|
||||||
|
method: "POST",
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
} catch (_) {}
|
||||||
|
refresh({ silent: true });
|
||||||
|
}
|
||||||
|
|
||||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||||
const LUNAR_MONTHS = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
|
const LUNAR_MONTHS = ["正", "二", "三", "四", "五", "六", "七", "八", "九", "十", "冬", "腊"];
|
||||||
const LUNAR_INFO = [
|
const LUNAR_INFO = [
|
||||||
@@ -1001,12 +1070,19 @@
|
|||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
const btn = document.getElementById("btn-refresh");
|
const btn = document.getElementById("btn-refresh");
|
||||||
if (btn) btn.addEventListener("click", refresh);
|
if (btn) btn.addEventListener("click", function () {
|
||||||
|
requestOverviewRefresh();
|
||||||
|
});
|
||||||
tickBeijingClock();
|
tickBeijingClock();
|
||||||
setInterval(tickBeijingClock, 1000);
|
setInterval(tickBeijingClock, 1000);
|
||||||
|
// 先进缓存秒开,再挂 SSE;不再前端定时现场拉实例
|
||||||
refresh();
|
refresh();
|
||||||
setInterval(refresh, 10000);
|
connectOverviewStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
global.HubOverview = { boot: boot, refresh: refresh };
|
global.HubOverview = {
|
||||||
|
boot: boot,
|
||||||
|
refresh: refresh,
|
||||||
|
requestRefresh: requestOverviewRefresh,
|
||||||
|
};
|
||||||
})(window);
|
})(window);
|
||||||
|
|||||||
@@ -15,6 +15,6 @@
|
|||||||
<div id="overview-grid"></div>
|
<div id="overview-grid"></div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='hub.js') }}?v=28"></script>
|
<script src="{{ url_for('static', filename='hub.js') }}?v=29"></script>
|
||||||
<script>HubOverview.boot();</script>
|
<script>HubOverview.boot();</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user