Add SSE second-level dashboard stream and 2s client heartbeat.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-17 21:04:55 +08:00
parent ba74a9cce8
commit d860156b11
8 changed files with 114 additions and 62 deletions
+2 -1
View File
@@ -141,7 +141,8 @@ def list_machines(conn: sqlite3.Connection) -> list[dict]:
return [row_to_dict(r) for r in cur.fetchall()]
ONLINE_SECONDS = 180 # 与下载中心一致:3 分钟内心跳视为在线
# 客户端约 2 秒心跳一次;超过该秒数无心跳视为离线(秒级大屏)
ONLINE_SECONDS = 8
def list_machines_for_user(conn: sqlite3.Connection, user: dict) -> list[dict]:
+39 -9
View File
@@ -3,11 +3,13 @@ from __future__ import annotations
import json
import secrets
import time
from datetime import datetime
from functools import wraps
from flask import (
Flask,
Response,
flash,
g,
jsonify,
@@ -16,6 +18,7 @@ from flask import (
request,
send_from_directory,
session,
stream_with_context,
url_for,
)
@@ -222,14 +225,11 @@ def register_routes(app: Flask) -> None:
data = _dashboard_payload(current_user())
return render_template("dashboard.html", **data)
@app.get("/api/dashboard/live")
@login_required
def dashboard_live():
"""总览轮询:在线机器 + 实时成交。"""
data = _dashboard_payload(current_user())
return jsonify(
{
def _dashboard_live_dict(u: dict) -> dict:
data = _dashboard_payload(u)
return {
"ok": True,
"ts": database.now(),
"online_count": data["online_count"],
"machine_count": data["machine_count"],
"online_machines": [
@@ -257,7 +257,37 @@ def register_routes(app: Flask) -> None:
for t in data["trades"]
],
}
)
@app.get("/api/dashboard/live")
@login_required
def dashboard_live():
"""总览快照(兼容轮询)。"""
return jsonify(_dashboard_live_dict(current_user()))
@app.get("/api/dashboard/stream")
@login_required
def dashboard_stream():
"""秒级 SSE 推送:在线机器 + 实时成交。"""
u = current_user()
@stream_with_context
def event_stream():
# 立即推一帧,后续约每秒一帧
while True:
try:
payload = json.dumps(_dashboard_live_dict(u), ensure_ascii=False)
yield f"data: {payload}\n\n"
except GeneratorExit:
return
except Exception:
yield 'event: error\ndata: {"ok":false}\n\n'
time.sleep(1)
resp = Response(event_stream(), mimetype="text/event-stream")
resp.headers["Cache-Control"] = "no-cache"
resp.headers["X-Accel-Buffering"] = "no"
resp.headers["Connection"] = "keep-alive"
return resp
# ---------- 账户(仅管理员创建)----------
@app.get("/accounts")
@@ -404,7 +434,7 @@ def register_routes(app: Flask) -> None:
).fetchall()
activations = []
now_ts = database.now()
online_sec = 180 # 3 分钟内有心跳视为在线
online_sec = database.ONLINE_SECONDS
for r in acts:
item = database.row_to_dict(r)
last = item.get("machine_last_seen")
+31 -12
View File
@@ -1,7 +1,7 @@
{% extends "base.html" %}
{% block title %}总览 · {{ app_name }}{% endblock %}
{% block content %}
<h2 style="margin:0 0 14px">总览</h2>
<h2 style="margin:0 0 14px">总览 <span class="muted" style="font-size:13px;font-weight:400" id="live-hint">· 秒级推送</span></h2>
<div class="grid cols-3">
<div class="card"><div class="muted">账户</div><div class="stat">{{ accounts|length }}</div></div>
<div class="card">
@@ -13,7 +13,7 @@
<div class="grid cols-2" style="margin-top:14px">
<div class="card">
<h3>在线机器 <span class="muted" style="font-weight:400;font-size:12px">· 约 5 秒刷新</span></h3>
<h3>在线机器</h3>
<table>
<thead><tr><th>机器</th><th>状态</th><th>最近心跳</th></tr></thead>
<tbody id="online-machines-body">
@@ -24,7 +24,7 @@
<td class="muted">{{ m.last_seen_fmt }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">当前无在线机器(客户端运行中会每分钟心跳)</td></tr>
<tr><td colspan="3" class="muted">当前无在线机器(客户端约每 2 秒心跳)</td></tr>
{% endfor %}
</tbody>
</table>
@@ -41,7 +41,7 @@
</div>
<div class="card" style="margin-top:14px">
<h3>机器实时交易记录 <span class="muted" style="font-weight:400;font-size:12px">· 自动刷新</span></h3>
<h3>机器实时交易记录</h3>
<table>
<thead>
<tr>
@@ -74,8 +74,10 @@
{% block scripts %}
<script>
(function () {
var streamUrl = {{ url_for('dashboard_stream')|tojson }};
var liveUrl = {{ url_for('dashboard_live')|tojson }};
var detailBase = {{ url_for('record_detail', tid=0)|tojson }}.replace(/0$/, '');
var hint = document.getElementById('live-hint');
function esc(s) {
return String(s == null ? '' : s)
@@ -87,7 +89,7 @@
var body = document.getElementById('online-machines-body');
if (!body) return;
if (!list.length) {
body.innerHTML = '<tr><td colspan="3" class="muted">当前无在线机器(客户端运行中会每分钟心跳)</td></tr>';
body.innerHTML = '<tr><td colspan="3" class="muted">当前无在线机器(客户端约每 2 秒心跳)</td></tr>';
return;
}
body.innerHTML = list.map(function (m) {
@@ -129,19 +131,36 @@
}).join('');
}
function tick() {
fetch(liveUrl, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (data) {
function apply(data) {
if (!data || !data.ok) return;
document.getElementById('stat-online').textContent = String(data.online_count);
renderMachines(data.online_machines || []);
renderTrades(data.trades || []);
})
.catch(function () {});
}
setInterval(tick, 5000);
function startPollFallback() {
if (hint) hint.textContent = '· 轮询 1s(SSE 不可用)';
setInterval(function () {
fetch(liveUrl, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(apply)
.catch(function () {});
}, 1000);
}
if (typeof EventSource === 'undefined') {
startPollFallback();
return;
}
var es = new EventSource(streamUrl);
es.onmessage = function (ev) {
try { apply(JSON.parse(ev.data)); } catch (e) {}
};
es.onerror = function () {
es.close();
startPollFallback();
};
})();
</script>
{% endblock %}
+2 -1
View File
@@ -11,7 +11,8 @@ module.exports = {
name: "gold_monitor",
cwd: root,
script: path.join(root, ".venv/bin/gunicorn"),
args: "wsgi:app --bind 0.0.0.0:5800 --workers 2 --threads 4 --timeout 120",
// threads 便于 SSE 长连接;timeout 拉长避免秒级推送被 worker 杀掉
args: "wsgi:app --bind 0.0.0.0:5800 --workers 2 --threads 8 --timeout 300 --keep-alive 5",
interpreter: "none",
autorestart: true,
max_restarts: 20,
+1 -1
View File
@@ -5,7 +5,7 @@
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
- **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行**
- **打开复盘**:用已保存的中控地址打开 `/login`
- **心跳**:运行中每分钟 ping 中控,网页下载中心显示在线/离线
- **心跳**:约每 2 秒 ping 中控,配合总览 SSE 秒级在线状态
- **自动上报**:开仓/平仓成功后上报成交与四周期 K 线
## 本地配置
+6 -7
View File
@@ -1447,23 +1447,22 @@ class OrderApp(tk.Tk):
self._run_async(work)
def _hub_heartbeat(self) -> None:
"""定期 ping 中控,供网页显示在线;断线则提示(不允许离线运行)。"""
"""约每 2 秒 ping 中控,供总览秒级在线状态。"""
interval_ms = 2000
if hub_client is None or not is_activated():
self.after(60000, self._hub_heartbeat)
self.after(interval_ms, self._hub_heartbeat)
return
def work():
ok = False
try:
ok = bool(hub_client.ping())
return bool(hub_client.ping())
except Exception:
ok = False
return ok
return False
def done(ok: bool):
if not ok:
self.set_status("中控离线:请检查服务器,客户端需保持在线", ok=False)
self.after(60000, self._hub_heartbeat)
self.after(interval_ms, self._hub_heartbeat)
def run():
ok = work()
+4 -2
View File
@@ -3,11 +3,13 @@
## 1. 架构
```
Browser (复盘/管理) ──HTTP:5800──► Flask(gunicorn) + SQLite
Browser (复盘大屏) ──SSE/HTTP:5800──► Flask(gunicorn threads) + SQLite
▲
Windows exe (通用包+激活) ──API───────────┘
Windows exe (心跳约2s + 成交上报) ───────┘
│
└── MetaTrader5 (本机)
总览秒级:`GET /api/dashboard/stream`(SSE,约 1 秒一帧);在线判定约 8 秒无心跳为离线。
```
- **不直连数据库**:多机只走 HTTP API。
Binary file not shown.