Files
dekun 53863559f4 Initialize crypto_monitor_user (user edition) from monitor codebase.
Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 16:18:13 +08:00

123 lines
3.9 KiB
Python

"""实例 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 notify_instance_balance_changed() -> int:
"""划转/兑换后通知 embed 壳拉最新资金快照."""
return instance_live_push.tick("balance")
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",
},
)