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