feat: instance embed SSE live push and lighter tab revisit
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -60,45 +60,52 @@
|
||||
else history.pushState({ embedTab: tab }, "", url);
|
||||
}
|
||||
|
||||
function runPageInit(tab) {
|
||||
function runPageInit(tab, opts) {
|
||||
const options = opts || {};
|
||||
const revisit = !!options.revisit;
|
||||
document.body.setAttribute("data-page", tab);
|
||||
if (typeof global.attachListWindowToExports === "function") {
|
||||
global.attachListWindowToExports();
|
||||
}
|
||||
if (tab === "trade") {
|
||||
if (typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
|
||||
if (typeof global.initOrderEntryModelSelect === "function") {
|
||||
if (!revisit && typeof global.refreshOrderDefaults === "function") global.refreshOrderDefaults();
|
||||
if (!revisit && typeof global.initOrderEntryModelSelect === "function") {
|
||||
const root = pageRoot() || document;
|
||||
global.initOrderEntryModelSelect(root);
|
||||
}
|
||||
if (global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
|
||||
if (!revisit && global.ManualOrderRrPreview && typeof global.ManualOrderRrPreview.wire === "function") {
|
||||
global.ManualOrderRrPreview.wire();
|
||||
}
|
||||
}
|
||||
if (tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
|
||||
if (!revisit && tab === "key_monitor" && global.KeyMonitorForm && typeof global.KeyMonitorForm.init === "function") {
|
||||
global.KeyMonitorForm.init();
|
||||
}
|
||||
if (tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
||||
if (!revisit && tab === "strategy" && typeof global.initStrategyRollForm === "function") {
|
||||
global.initStrategyRollForm();
|
||||
}
|
||||
if (tab === "records") {
|
||||
if (!revisit && tab === "records") {
|
||||
if (typeof global.loadJournals === "function") global.loadJournals();
|
||||
if (typeof global.loadReviews === "function") global.loadReviews();
|
||||
if (typeof global.toggleReviewMode === "function") global.toggleReviewMode();
|
||||
}
|
||||
if (tab === "stats") {
|
||||
if (!revisit && tab === "stats") {
|
||||
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
|
||||
}
|
||||
if (typeof global.refreshPriceSnapshotConditional === "function") {
|
||||
global.refreshPriceSnapshotConditional();
|
||||
if (revisit && tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
|
||||
global.OptionsPanelLive.refreshSoft();
|
||||
}
|
||||
if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") {
|
||||
const root = pageRoot() || document;
|
||||
global.SymbolLivePrice.init(root);
|
||||
}
|
||||
if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
|
||||
const root = pageRoot() || document;
|
||||
global.JournalUploadSlots.init(root);
|
||||
if (!revisit) {
|
||||
if (typeof global.refreshPriceSnapshotConditional === "function") {
|
||||
global.refreshPriceSnapshotConditional();
|
||||
}
|
||||
if (global.SymbolLivePrice && typeof global.SymbolLivePrice.init === "function") {
|
||||
const root = pageRoot() || document;
|
||||
global.SymbolLivePrice.init(root);
|
||||
}
|
||||
if (global.JournalUploadSlots && typeof global.JournalUploadSlots.init === "function") {
|
||||
const root = pageRoot() || document;
|
||||
global.JournalUploadSlots.init(root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,7 +199,7 @@
|
||||
showPane(tab);
|
||||
setNavActive(tab);
|
||||
if (!options.skipUrl) syncUrl(tab, !!options.replace);
|
||||
runPageInit(tab);
|
||||
runPageInit(tab, { revisit: !!options.revisit });
|
||||
}
|
||||
|
||||
async function loadTab(tab, opts) {
|
||||
@@ -200,7 +207,7 @@
|
||||
if (!tab) return;
|
||||
|
||||
if (tabPanes.has(tab) && !options.force) {
|
||||
activateTab(tab, options);
|
||||
activateTab(tab, Object.assign({}, options, { revisit: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* embed 壳:SSE 收到后台 tick 后拉 JSON 快照更新 DOM,切换 tab 不再重复请求 HTML。
|
||||
*/
|
||||
(function (global) {
|
||||
let liveEventSource = null;
|
||||
let liveReconnectTimer = null;
|
||||
let localLiveVersion = -1;
|
||||
let sseConnected = false;
|
||||
|
||||
function isEmbedShell() {
|
||||
return document.body && document.body.getAttribute("data-embed-shell") === "1";
|
||||
}
|
||||
|
||||
function currentTab() {
|
||||
if (global.InstanceEmbed && typeof global.InstanceEmbed.getTab === "function") {
|
||||
return global.InstanceEmbed.getTab();
|
||||
}
|
||||
return document.body.getAttribute("data-page") || "trade";
|
||||
}
|
||||
|
||||
function refreshTabData(tab, opts) {
|
||||
const options = opts || {};
|
||||
if (typeof global.refreshAccountSnapshot === "function") {
|
||||
global.refreshAccountSnapshot();
|
||||
}
|
||||
if (typeof global.refreshPriceSnapshotConditional === "function") {
|
||||
global.refreshPriceSnapshotConditional();
|
||||
}
|
||||
if (tab === "options" && global.OptionsPanelLive && typeof global.OptionsPanelLive.refreshSoft === "function") {
|
||||
global.OptionsPanelLive.refreshSoft(options);
|
||||
}
|
||||
}
|
||||
|
||||
function onLiveEvent(data) {
|
||||
const ver = Number(data && data.live_version) || 0;
|
||||
if (!ver || ver === localLiveVersion) return;
|
||||
localLiveVersion = ver;
|
||||
refreshTabData(currentTab(), { silent: true });
|
||||
}
|
||||
|
||||
function closeLiveStream() {
|
||||
if (liveEventSource) {
|
||||
liveEventSource.close();
|
||||
liveEventSource = null;
|
||||
}
|
||||
if (liveReconnectTimer) {
|
||||
clearTimeout(liveReconnectTimer);
|
||||
liveReconnectTimer = null;
|
||||
}
|
||||
sseConnected = false;
|
||||
}
|
||||
|
||||
function connectLiveStream() {
|
||||
if (!isEmbedShell()) return;
|
||||
closeLiveStream();
|
||||
liveEventSource = new EventSource("/api/instance/live/stream");
|
||||
liveEventSource.addEventListener("live", function (ev) {
|
||||
try {
|
||||
onLiveEvent(JSON.parse(ev.data || "{}"));
|
||||
} catch (_) {}
|
||||
});
|
||||
liveEventSource.onopen = function () {
|
||||
sseConnected = true;
|
||||
};
|
||||
liveEventSource.onerror = function () {
|
||||
sseConnected = false;
|
||||
closeLiveStream();
|
||||
liveReconnectTimer = setTimeout(function () {
|
||||
connectLiveStream();
|
||||
refreshTabData(currentTab(), { silent: true });
|
||||
}, 8000);
|
||||
};
|
||||
}
|
||||
|
||||
function startLive() {
|
||||
if (!isEmbedShell()) return;
|
||||
refreshTabData(currentTab());
|
||||
connectLiveStream();
|
||||
}
|
||||
|
||||
global.InstanceLive = {
|
||||
start: startLive,
|
||||
refreshTabData: refreshTabData,
|
||||
isConnected: function () {
|
||||
return sseConnected;
|
||||
},
|
||||
};
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", startLive);
|
||||
} else {
|
||||
startLive();
|
||||
}
|
||||
})(typeof window !== "undefined" ? window : globalThis);
|
||||
@@ -473,9 +473,6 @@
|
||||
renderExpiries();
|
||||
renderStrikes();
|
||||
refreshAllPositions();
|
||||
requestAnimationFrame(function () {
|
||||
loadChain();
|
||||
});
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(function () {
|
||||
@@ -523,4 +520,11 @@
|
||||
});
|
||||
|
||||
bootOptionsPanel();
|
||||
|
||||
window.OptionsPanelLive = {
|
||||
refreshSoft: function () {
|
||||
refreshAllPositions();
|
||||
},
|
||||
refreshChain: loadChain,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -68,6 +68,7 @@ def install_instance_theme_static(app) -> None:
|
||||
"strategy_roll.js": "application/javascript; charset=utf-8",
|
||||
"instance_page.css": "text/css; charset=utf-8",
|
||||
"instance_embed.js": "application/javascript; charset=utf-8",
|
||||
"instance_live.js": "application/javascript; charset=utf-8",
|
||||
"order_entry_model.js": "application/javascript; charset=utf-8",
|
||||
"focus_chart_page.js": "application/javascript; charset=utf-8",
|
||||
"focus_chart_page.css": "text/css; charset=utf-8",
|
||||
|
||||
@@ -144,7 +144,10 @@ def register_embed_routes(
|
||||
login_required: Callable,
|
||||
render_main_page_fn: Callable,
|
||||
) -> None:
|
||||
from lib.instance.instance_live_push_lib import register_instance_live_routes
|
||||
|
||||
app.config["RENDER_MAIN_PAGE_FN"] = render_main_page_fn
|
||||
register_instance_live_routes(app, login_required)
|
||||
|
||||
@login_required
|
||||
@app.route("/embed")
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
"""实例 embed 壳:后台定时 tick + SSE 通知前端拉 JSON 快照(对齐中控 dashboard)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import queue
|
||||
import threading
|
||||
from collections.abc import Iterator
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import Flask, Response, stream_with_context
|
||||
|
||||
INSTANCE_LIVE_TICK_SEC = float(os.getenv("INSTANCE_LIVE_TICK_SEC", "5"))
|
||||
INSTANCE_SSE_HEARTBEAT_SEC = float(os.getenv("INSTANCE_SSE_HEARTBEAT_SEC", "25"))
|
||||
|
||||
|
||||
class InstanceLivePush:
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self.version = 0
|
||||
self._subscribers: list[queue.Queue[str | None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._thread: threading.Thread | None = None
|
||||
|
||||
def start(self) -> None:
|
||||
if self._thread and self._thread.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._thread = threading.Thread(target=self._loop, daemon=True, name="instance-live-push")
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
self._broadcast(close=True)
|
||||
|
||||
def tick(self, reason: str = "poll") -> int:
|
||||
with self._lock:
|
||||
self.version += 1
|
||||
ver = self.version
|
||||
payload = json.dumps({"live_version": ver, "reason": reason}, ensure_ascii=False)
|
||||
self._broadcast(payload)
|
||||
return ver
|
||||
|
||||
def event_dict(self) -> dict[str, Any]:
|
||||
return {"live_version": self.version, "tick_sec": INSTANCE_LIVE_TICK_SEC}
|
||||
|
||||
def _loop(self) -> None:
|
||||
while not self._stop.is_set():
|
||||
self.tick("poll")
|
||||
if self._stop.wait(INSTANCE_LIVE_TICK_SEC):
|
||||
break
|
||||
|
||||
def _broadcast(self, event: str | None = None, *, close: bool = False) -> None:
|
||||
with self._lock:
|
||||
subs = list(self._subscribers)
|
||||
dead: list[queue.Queue[str | None]] = []
|
||||
for q in subs:
|
||||
try:
|
||||
q.put_nowait(None if close else event)
|
||||
except Exception:
|
||||
dead.append(q)
|
||||
if dead:
|
||||
with self._lock:
|
||||
for q in dead:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def _subscribe(self) -> queue.Queue[str | None]:
|
||||
q: queue.Queue[str | None] = queue.Queue(maxsize=16)
|
||||
with self._lock:
|
||||
self._subscribers.append(q)
|
||||
return q
|
||||
|
||||
def _unsubscribe(self, q: queue.Queue[str | None]) -> None:
|
||||
with self._lock:
|
||||
if q in self._subscribers:
|
||||
self._subscribers.remove(q)
|
||||
|
||||
def iter_sse(self) -> Iterator[str]:
|
||||
q = self._subscribe()
|
||||
try:
|
||||
yield self._format_event(self.event_dict() | {"reason": "connect"})
|
||||
while True:
|
||||
try:
|
||||
raw = q.get(timeout=INSTANCE_SSE_HEARTBEAT_SEC)
|
||||
except queue.Empty:
|
||||
yield ": heartbeat\n\n"
|
||||
continue
|
||||
if raw is None:
|
||||
break
|
||||
yield f"event: live\ndata: {raw}\n\n"
|
||||
finally:
|
||||
self._unsubscribe(q)
|
||||
|
||||
@staticmethod
|
||||
def _format_event(data: dict[str, Any]) -> str:
|
||||
return "event: live\ndata: " + json.dumps(data, ensure_ascii=False) + "\n\n"
|
||||
|
||||
|
||||
instance_live_push = InstanceLivePush()
|
||||
|
||||
|
||||
def register_instance_live_routes(app: Flask, login_required: Callable) -> None:
|
||||
instance_live_push.start()
|
||||
|
||||
@login_required
|
||||
@app.route("/api/instance/live/stream")
|
||||
def api_instance_live_stream():
|
||||
return Response(
|
||||
stream_with_context(instance_live_push.iter_sse()),
|
||||
mimetype="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
)
|
||||
@@ -1268,7 +1268,6 @@ if(addOrderForm){
|
||||
refreshOrderDefaults();
|
||||
if(typeof initOrderEntryModelSelect === "function") initOrderEntryModelSelect();
|
||||
refreshPriceSnapshotConditional();
|
||||
setInterval(refreshAccountSnapshot, Number(document.body.dataset.balanceRefreshMs || 30000));
|
||||
function refreshPriceSnapshotConditional(){
|
||||
const page = document.body.getAttribute("data-page") || "";
|
||||
fetch("/api/price_snapshot").then(r=>r.json()).then(data=>{
|
||||
@@ -1348,5 +1347,22 @@ function tickOrderHoldDurations(){
|
||||
}
|
||||
setInterval(tickOrderHoldDurations, 1000);
|
||||
tickOrderHoldDurations();
|
||||
setInterval(refreshPriceSnapshotConditional, Number(document.body.dataset.priceRefreshMs || 5000));
|
||||
(function startInstanceDataRefresh(){
|
||||
const embedShell = document.body && document.body.getAttribute("data-embed-shell") === "1";
|
||||
const balanceMs = Number(document.body.dataset.balanceRefreshMs || 30000);
|
||||
const priceMs = Number(document.body.dataset.priceRefreshMs || 5000);
|
||||
if(embedShell){
|
||||
setInterval(function(){
|
||||
if(window.InstanceLive && InstanceLive.isConnected && InstanceLive.isConnected()) return;
|
||||
refreshAccountSnapshot();
|
||||
}, balanceMs);
|
||||
setInterval(function(){
|
||||
if(window.InstanceLive && InstanceLive.isConnected && InstanceLive.isConnected()) return;
|
||||
refreshPriceSnapshotConditional();
|
||||
}, priceMs);
|
||||
return;
|
||||
}
|
||||
setInterval(refreshAccountSnapshot, balanceMs);
|
||||
setInterval(refreshPriceSnapshotConditional, priceMs);
|
||||
})();
|
||||
</script>
|
||||
|
||||
@@ -90,6 +90,7 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
||||
<script src="/static/strategy_roll.js?v=6"></script>
|
||||
<script src="/static/key_monitor_form.js?v=2"></script>
|
||||
{% include 'embed_boot_scripts.html' %}
|
||||
<script src="/static/instance_embed.js?v=9"></script>
|
||||
<script src="/static/instance_live.js?v=1"></script>
|
||||
<script src="/static/instance_embed.js?v=10"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
"""instance_live_push_lib 单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from lib.instance.instance_live_push_lib import InstanceLivePush
|
||||
|
||||
|
||||
def test_tick_increments_version_and_connect_event() -> None:
|
||||
push = InstanceLivePush()
|
||||
v1 = push.tick("test")
|
||||
v2 = push.tick("test")
|
||||
assert v1 == 1
|
||||
assert v2 == 2
|
||||
gen = push.iter_sse()
|
||||
first = next(gen)
|
||||
assert first.startswith("event: live")
|
||||
data = json.loads(first.split("data: ", 1)[1].strip())
|
||||
assert data["live_version"] == 2
|
||||
push.stop()
|
||||
Reference in New Issue
Block a user