Dashboard: show online machines and live trade feed with polling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-17 20:35:16 +08:00
parent 44c54d6f80
commit 679b55f1db
3 changed files with 214 additions and 28 deletions
+33
View File
@@ -141,6 +141,39 @@ 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 分钟内心跳视为在线
def list_machines_for_user(conn: sqlite3.Connection, user: dict) -> list[dict]:
if user.get("role") == "admin":
items = list_machines(conn)
else:
cur = conn.execute(
"""
SELECT DISTINCT m.* FROM machines m
JOIN activations act ON act.machine_id = m.id
WHERE act.account_id=?
ORDER BY m.id DESC
""",
(user.get("account_id"),),
)
items = [row_to_dict(r) for r in cur.fetchall()]
return annotate_machines_online(items)
def annotate_machines_online(machines: list[dict], now_ts: Optional[float] = None) -> list[dict]:
ts = now_ts if now_ts is not None else now()
for m in machines:
last = m.get("last_seen_at")
try:
online = bool(last and (ts - float(last)) <= ONLINE_SECONDS)
except (TypeError, ValueError):
online = False
m["online"] = online
m["online_label"] = "在线" if online else "离线"
return machines
def create_activation( def create_activation(
conn: sqlite3.Connection, conn: sqlite3.Connection,
account_id: int, account_id: int,
+69 -13
View File
@@ -179,28 +179,84 @@ def register_routes(app: Flask) -> None:
flash(str(exc), "error") flash(str(exc), "error")
return redirect(url_for("users_page")) return redirect(url_for("users_page"))
@app.get("/") def _fmt_ts(value) -> str:
@login_required if value is None or value == "":
def dashboard(): return "—"
u = current_user() try:
return datetime.fromtimestamp(float(value)).strftime("%Y-%m-%d %H:%M:%S")
except (TypeError, ValueError, OSError):
return str(value)
def _dashboard_payload(u: dict) -> dict:
with database.get_db() as conn: with database.get_db() as conn:
if u["role"] == "admin": if u["role"] == "admin":
accounts = database.list_accounts(conn) accounts = database.list_accounts(conn)
machines = database.list_machines(conn) trades = database.list_trades(conn, 40)
trades = database.list_trades(conn, 20)
else: else:
accounts = [ accounts = [
a a
for a in database.list_accounts(conn) for a in database.list_accounts(conn)
if a["id"] == u.get("account_id") if a["id"] == u.get("account_id")
] ]
machines = database.list_machines(conn) trades = database.list_trades(conn, 40, account_id=u.get("account_id"))
trades = database.list_trades(conn, 20, account_id=u.get("account_id")) machines = database.list_machines_for_user(conn, u)
return render_template( online_machines = [m for m in machines if m.get("online")]
"dashboard.html", for m in machines:
accounts=accounts, m["last_seen_fmt"] = _fmt_ts(m.get("last_seen_at"))
machines=machines, for t in trades:
trades=trades, t["time_fmt"] = _fmt_ts(
t.get("close_time") or t.get("open_time") or t.get("created_at")
)
return {
"accounts": accounts,
"machines": machines,
"online_machines": online_machines,
"online_count": len(online_machines),
"machine_count": len(machines),
"trades": trades,
}
@app.get("/")
@login_required
def dashboard():
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(
{
"ok": True,
"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"]
],
}
) )
# ---------- 账户(仅管理员创建)---------- # ---------- 账户(仅管理员创建)----------
+112 -15
View File
@@ -4,47 +4,144 @@
<h2 style="margin:0 0 14px">总览</h2> <h2 style="margin:0 0 14px">总览</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="muted">机器</div><div class="stat">{{ machines|length }}</div></div> <div class="card">
<div class="card"><div class="muted">最近成交(展示)</div><div class="stat">{{ trades|length }}</div></div> <div class="muted">在线机器</div>
<div class="stat"><span id="stat-online">{{ online_count }}</span><span class="muted" style="font-size:16px"> / {{ machine_count }}</span></div>
</div>
<div class="card"><div class="muted">实时成交(条)</div><div class="stat" id="stat-trades">{{ trades|length }}</div></div>
</div> </div>
<div class="grid cols-2" style="margin-top:14px"> <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>
<table>
<thead><tr><th>机器</th><th>状态</th><th>最近心跳</th></tr></thead>
<tbody id="online-machines-body">
{% for m in online_machines %}
<tr>
<td>{{ m.name }} <span class="muted">#{{ m.id }}</span></td>
<td><span class="tag green">在线</span></td>
<td class="muted">{{ m.last_seen_fmt }}</td>
</tr>
{% else %}
<tr><td colspan="3" class="muted">当前无在线机器(客户端运行中会每分钟心跳)</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card"> <div class="card">
<h3>快速入口</h3> <h3>快速入口</h3>
<p class="muted">登录后下载通用辅助包,用激活码绑定机器与账户;成交自动上报,网页复盘四周期 K 线。</p> <p class="muted">下载通用辅助包并用激活码绑定;成交自动上报到本页实时记录与交易复盘。</p>
<p style="margin-top:12px"> <p style="margin-top:12px">
<a class="btn" href="{{ url_for('download_page') }}">下载中心</a> <a class="btn" href="{{ url_for('download_page') }}">下载中心</a>
<a class="btn secondary" href="{{ url_for('records_page') }}">交易复盘</a> <a class="btn secondary" href="{{ url_for('records_page') }}">交易复盘</a>
</p> </p>
</div> <p class="muted" style="margin-top:14px">对外地址:{{ public_base }}</p>
<div class="card">
<h3>服务信息</h3>
<p class="muted">对外地址:{{ public_base }}</p>
<p class="muted">端口:5800 · 健康检查:<a href="/health">/health</a></p>
</div> </div>
</div> </div>
<div class="card" style="margin-top:14px"> <div class="card" style="margin-top:14px">
<h3>最近记录</h3> <h3>机器实时交易记录 <span class="muted" style="font-weight:400;font-size:12px">· 自动刷新</span></h3>
<table> <table>
<thead> <thead>
<tr><th>时间</th><th>品种</th><th>方向</th><th>盈亏</th><th>账户</th><th>入场形式</th><th></th></tr> <tr>
<th>时间</th><th>机器</th><th>品种</th><th>方向</th><th>手数</th>
<th>盈亏</th><th>账户</th><th>单号</th><th></th>
</tr>
</thead> </thead>
<tbody> <tbody id="live-trades-body">
{% for t in trades %} {% for t in trades %}
<tr> <tr>
<td class="muted">{{ t.close_time or t.open_time or t.created_at }}</td> <td class="muted">{{ t.time_fmt }}</td>
<td>{{ t.machine_name or '—' }}</td>
<td>{{ t.symbol }}</td> <td>{{ t.symbol }}</td>
<td><span class="tag {% if t.side=='buy' %}green{% else %}red{% endif %}">{{ t.side }}</span></td> <td><span class="tag {% if t.side=='buy' %}green{% else %}red{% endif %}">{{ t.side }}</span></td>
<td>{{ t.profit if t.profit is not none else '—' }}</td> <td>{{ t.volume if t.volume is not none else '—' }}</td>
<td style="color:{% if t.profit and t.profit>0 %}var(--green){% elif t.profit and t.profit<0 %}var(--red){% else %}inherit{% endif %}">
{{ t.profit if t.profit is not none else '—' }}
</td>
<td>{{ t.account_name or '—' }}</td> <td>{{ t.account_name or '—' }}</td>
<td>{{ t.entry_style or '—' }}</td> <td class="muted">{{ t.ticket or '—' }}</td>
<td><a href="{{ url_for('record_detail', tid=t.id) }}">复盘</a></td> <td><a href="{{ url_for('record_detail', tid=t.id) }}">复盘</a></td>
</tr> </tr>
{% else %} {% else %}
<tr><td colspan="7" class="muted">暂无成交。激活客户端并上报后显示。</td></tr> <tr><td colspan="9" class="muted">暂无成交。客户端上报后将实时显示。</td></tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script>
(function () {
var liveUrl = {{ url_for('dashboard_live')|tojson }};
var detailBase = {{ url_for('record_detail', tid=0)|tojson }}.replace(/0$/, '');
function esc(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
function renderMachines(list) {
var body = document.getElementById('online-machines-body');
if (!body) return;
if (!list.length) {
body.innerHTML = '<tr><td colspan="3" class="muted">当前无在线机器(客户端运行中会每分钟心跳)</td></tr>';
return;
}
body.innerHTML = list.map(function (m) {
return '<tr><td>' + esc(m.name) + ' <span class="muted">#' + esc(m.id) + '</span></td>' +
'<td><span class="tag green">在线</span></td>' +
'<td class="muted">' + esc(m.last_seen_fmt || '—') + '</td></tr>';
}).join('');
}
function profitColor(p) {
if (p == null || p === '') return 'inherit';
var n = Number(p);
if (n > 0) return 'var(--green)';
if (n < 0) return 'var(--red)';
return 'inherit';
}
function renderTrades(list) {
var body = document.getElementById('live-trades-body');
if (!body) return;
document.getElementById('stat-trades').textContent = String(list.length);
if (!list.length) {
body.innerHTML = '<tr><td colspan="9" class="muted">暂无成交。客户端上报后将实时显示。</td></tr>';
return;
}
body.innerHTML = list.map(function (t) {
var sideCls = t.side === 'buy' ? 'green' : 'red';
return '<tr>' +
'<td class="muted">' + esc(t.time_fmt) + '</td>' +
'<td>' + esc(t.machine_name || '—') + '</td>' +
'<td>' + esc(t.symbol) + '</td>' +
'<td><span class="tag ' + sideCls + '">' + esc(t.side) + '</span></td>' +
'<td>' + esc(t.volume != null ? t.volume : '—') + '</td>' +
'<td style="color:' + profitColor(t.profit) + '">' + esc(t.profit != null ? t.profit : '—') + '</td>' +
'<td>' + esc(t.account_name || '—') + '</td>' +
'<td class="muted">' + esc(t.ticket || '—') + '</td>' +
'<td><a href="' + detailBase + t.id + '">复盘</a></td>' +
'</tr>';
}).join('');
}
function tick() {
fetch(liveUrl, { credentials: 'same-origin' })
.then(function (r) { return r.json(); })
.then(function (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);
})();
</script>
{% endblock %}