Add SSE second-level dashboard stream and 2s client heartbeat.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
@@ -5,7 +5,7 @@
|
|||||||
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
|
- 连接本机已登录 MT5,市价开平、ATR 止损止盈、保本、满仓灰显等(由原 `MT5黄金方案/Desktop` 迁入)
|
||||||
- **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行**
|
- **通用包 + 首次激活**:仅第一次填写中控地址与激活码;**不允许离线运行**
|
||||||
- **打开复盘**:用已保存的中控地址打开 `/login`
|
- **打开复盘**:用已保存的中控地址打开 `/login`
|
||||||
- **心跳**:运行中每分钟 ping 中控,网页下载中心显示在线/离线
|
- **心跳**:约每 2 秒 ping 中控,配合总览 SSE 秒级在线状态
|
||||||
- **自动上报**:开仓/平仓成功后上报成交与四周期 K 线
|
- **自动上报**:开仓/平仓成功后上报成交与四周期 K 线
|
||||||
|
|
||||||
## 本地配置
|
## 本地配置
|
||||||
|
|||||||
@@ -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
@@ -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.
Reference in New Issue
Block a user