(function () {
const COLORS = [
"#22d3ee",
"#34d399",
"#fbbf24",
"#fb7185",
"#a78bfa",
"#60a5fa",
"#f472b6",
"#2dd4bf",
];
const PICKER_KEY = "hub-stats-curve-machines";
let windowKey = "7d";
let curveData = null;
let summaryData = null;
let selectedIds = loadSelectedIds();
let statsVersion = 0;
let statsLoading = false;
let statsEventSource = null;
let statsReconnectTimer = null;
function loadSelectedIds() {
try {
const raw = localStorage.getItem(PICKER_KEY);
if (!raw) return null;
const arr = JSON.parse(raw);
if (Array.isArray(arr)) return new Set(arr.map(String));
} catch (_) {}
return null;
}
function saveSelectedIds() {
try {
localStorage.setItem(PICKER_KEY, JSON.stringify(Array.from(selectedIds || [])));
} catch (_) {}
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """);
}
function fmtNum(v, digits) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return n.toFixed(digits == null ? 2 : digits);
}
function fmtPnl(v) {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return "—";
return (n > 0 ? "+" : "") + n.toFixed(2) + " U";
}
function pnlClass(v) {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "pnl-flat";
return n > 0 ? "pnl-up" : "pnl-down";
}
function fmtHold(sec) {
if (sec == null || sec === "") return "—";
const n = Number(sec);
if (!Number.isFinite(n) || n < 0) return "—";
if (n < 60) return Math.round(n) + "s";
if (n < 3600) return (n / 60).toFixed(1) + "m";
return (n / 3600).toFixed(1) + "h";
}
function modeLabel(mode) {
const m = String(mode || "").toLowerCase();
if (m === "sim") return "模拟";
if (m === "live") return "实盘";
return mode ? String(mode) : "—";
}
function setStatus(msg, isErr) {
const el = document.getElementById("stats-status");
if (!el) return;
el.textContent = msg || "";
el.classList.toggle("err", !!isErr);
}
function renderKpis(totals) {
const grid = document.getElementById("stats-kpi-grid");
if (!grid) return;
const t = totals || {};
const items = [
{ label: "在线 / 总数", value: (t.online || 0) + " / " + (t.machines || 0) },
{ label: "合计余额", value: fmtNum(t.cash_usdt) + " U" },
{ label: "窗口已实现", value: fmtPnl(t.net_realized_pnl), cls: pnlClass(t.net_realized_pnl) },
{ label: "胜率", value: fmtNum(t.win_rate, 1) + "%" },
{ label: "平仓笔数", value: String(t.total_closed != null ? t.total_closed : "—") },
{ label: "合计浮盈", value: fmtPnl(t.unrealized_usdt), cls: pnlClass(t.unrealized_usdt) },
];
grid.innerHTML = items
.map(function (it) {
return (
'
' +
'
' +
escapeHtml(it.label) +
"
" +
'
' +
escapeHtml(it.value) +
"
"
);
})
.join("");
}
function renderTable(machines) {
const body = document.getElementById("stats-table-body");
if (!body) return;
const list = machines || [];
if (!list.length) {
body.innerHTML = '| 暂无启用实例 |
';
return;
}
body.innerHTML = list
.map(function (m) {
const status = m.online
? '在线'
: '离线';
const err = m.error
? '' + escapeHtml(m.error) + "
"
: "";
const mode = String(m.mode || "").toLowerCase();
const modeCls = mode === "live" ? "stats-mode-live" : mode === "sim" ? "stats-mode-sim" : "";
return (
"" +
"| " +
escapeHtml(m.name || m.id) +
"" +
err +
" | " +
"" +
status +
" | " +
'' +
escapeHtml(modeLabel(m.mode)) +
" | " +
"" +
escapeHtml(fmtNum(m.cash_usdt)) +
" | " +
"" +
escapeHtml(fmtNum(m.premium_usdt)) +
" | " +
'' +
escapeHtml(fmtPnl(m.unrealized_usdt)) +
" | " +
"" +
escapeHtml(m.stats_ok ? String(m.total_closed ?? "—") : "—") +
" | " +
"" +
escapeHtml(m.stats_ok ? fmtNum(m.win_rate, 1) + "%" : "—") +
" | " +
'' +
escapeHtml(m.stats_ok ? fmtPnl(m.net_realized_pnl) : "—") +
" | " +
'' +
escapeHtml(
m.stats_ok && m.return_rate_pct != null ? fmtNum(m.return_rate_pct, 2) + "%" : "—"
) +
" | " +
"" +
escapeHtml(m.stats_ok ? fmtHold(m.avg_hold_sec) : "—") +
" | " +
'工作台 | ' +
"
"
);
})
.join("");
}
function ensureSelectedIds(series) {
const ids = (series || []).map(function (s) {
return String(s.id);
});
if (selectedIds == null) {
selectedIds = new Set(ids);
saveSelectedIds();
return;
}
// 新机器默认勾选
ids.forEach(function (id) {
if (!selectedIds.has(id) && !localStorage.getItem(PICKER_KEY + ":seen:" + id)) {
selectedIds.add(id);
try {
localStorage.setItem(PICKER_KEY + ":seen:" + id, "1");
} catch (_) {}
}
});
saveSelectedIds();
}
function renderMachinePicker(data) {
const box = document.getElementById("stats-machine-picker");
if (!box) return;
const series = (data && data.series) || [];
ensureSelectedIds(series);
if (!series.length) {
box.innerHTML = '暂无可选机器';
return;
}
box.innerHTML =
'' +
'' +
series
.map(function (s, i) {
const id = String(s.id);
const checked = selectedIds && selectedIds.has(id) ? " checked" : "";
const color = COLORS[i % COLORS.length];
return (
'"
);
})
.join("");
}
function drawCurve(data) {
const canvas = document.getElementById("stats-curve-canvas");
const empty = document.getElementById("stats-curve-empty");
const legend = document.getElementById("stats-curve-legend");
if (!canvas) return;
const ctx = canvas.getContext("2d");
const dpr = window.devicePixelRatio || 1;
const cssW = canvas.clientWidth || 960;
const wrap = canvas.parentElement;
const cssH = Math.max(420, (wrap && wrap.clientHeight) || 480);
canvas.width = Math.floor(cssW * dpr);
canvas.height = Math.floor(cssH * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, cssW, cssH);
const showTotal = !!(document.getElementById("stats-show-total") || {}).checked;
const lines = [];
if (showTotal && data && data.total && data.total.length) {
lines.push({ name: "合计", color: "#e8f1ff", points: data.total, width: 2.6 });
}
if (data && data.series) {
data.series.forEach(function (s, i) {
const id = String(s.id);
if (selectedIds && !selectedIds.has(id)) return;
if (!s.points || !s.points.length) return;
lines.push({
name: s.name || id,
color: COLORS[i % COLORS.length],
points: s.points,
width: 1.6,
});
});
}
if (legend) {
legend.innerHTML = lines
.map(function (ln) {
return (
'' +
escapeHtml(ln.name) +
""
);
})
.join("");
}
const allPts = lines.reduce(function (acc, ln) {
return acc.concat(ln.points || []);
}, []);
if (!allPts.length) {
if (empty) empty.hidden = false;
return;
}
if (empty) empty.hidden = true;
let minT = Math.min.apply(
null,
allPts.map(function (p) {
return p.t;
})
);
let maxT = Math.max.apply(
null,
allPts.map(function (p) {
return p.t;
})
);
let minV = Math.min.apply(
null,
allPts.map(function (p) {
return p.v;
})
);
let maxV = Math.max.apply(
null,
allPts.map(function (p) {
return p.v;
})
);
if (maxT <= minT) maxT = minT + 1;
if (maxV <= minV) {
maxV = minV + 1;
minV = minV - 1;
}
const pad = { l: 56, r: 18, t: 18, b: 32 };
const w = cssW - pad.l - pad.r;
const h = cssH - pad.t - pad.b;
function xOf(t) {
return pad.l + ((t - minT) / (maxT - minT)) * w;
}
function yOf(v) {
return pad.t + (1 - (v - minV) / (maxV - minV)) * h;
}
ctx.strokeStyle = "rgba(56,189,248,0.15)";
ctx.lineWidth = 1;
for (let i = 0; i <= 5; i++) {
const y = pad.t + (h * i) / 5;
ctx.beginPath();
ctx.moveTo(pad.l, y);
ctx.lineTo(pad.l + w, y);
ctx.stroke();
const val = maxV - ((maxV - minV) * i) / 5;
ctx.fillStyle = "#8aa0b8";
ctx.font = "11px IBM Plex Sans, sans-serif";
ctx.fillText(val.toFixed(0), 8, y + 4);
}
lines.forEach(function (ln) {
const pts = ln.points || [];
if (pts.length < 1) return;
ctx.strokeStyle = ln.color;
ctx.lineWidth = ln.width || 1.5;
ctx.beginPath();
pts.forEach(function (p, idx) {
const x = xOf(p.t);
const y = yOf(p.v);
if (idx === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.stroke();
});
}
async function loadAll(opts) {
opts = opts || {};
if (statsLoading) return;
statsLoading = true;
if (!opts.silent) setStatus("读取缓存…");
try {
const [sumRes, curveRes] = await Promise.all([
fetch("/api/stats/summary?window=" + encodeURIComponent(windowKey), {
credentials: "same-origin",
}),
fetch("/api/stats/curve?range=" + encodeURIComponent(windowKey), {
credentials: "same-origin",
}),
]);
const sum = await sumRes.json();
const curve = await curveRes.json();
if (!sum.ok && !(sum.machines && sum.machines.length)) {
if (sum.refreshing) {
setStatus("后台首次刷新统计中…");
return;
}
throw new Error(sum.msg || sum.error || "汇总失败");
}
if (!curve.ok) throw new Error(curve.msg || "曲线失败");
statsVersion = Number(sum.stats_version || curve.stats_version) || statsVersion;
summaryData = sum;
curveData = curve;
renderKpis(sum.totals || {});
renderTable(sum.machines || []);
renderMachinePicker(curve);
drawCurve(curve);
const sec = Number(sum.poll_interval_sec || curve.poll_interval_sec) || 15;
const live = statsEventSource && statsEventSource.readyState === 1;
setStatus(
(live ? "SSE 已连接 · " : "") +
"窗口 " +
windowKey +
" · 缓存 " +
((sum.totals && sum.totals.online) || 0) +
"/" +
((sum.totals && sum.totals.machines) || 0) +
(sum.updated_at ? " · " + sum.updated_at : "") +
(sum.refreshing ? " · 后台刷新中" : " · 后台每 " + sec + "s 刷新")
);
} catch (e) {
setStatus(String(e.message || e), true);
} finally {
statsLoading = false;
}
}
function closeStatsStream() {
if (statsEventSource) {
statsEventSource.close();
statsEventSource = null;
}
if (statsReconnectTimer) {
clearTimeout(statsReconnectTimer);
statsReconnectTimer = null;
}
}
function connectStatsStream() {
if (typeof EventSource === "undefined") return;
closeStatsStream();
statsEventSource = new EventSource("/api/stats/stream");
statsEventSource.addEventListener("stats", function (ev) {
try {
const st = JSON.parse(ev.data || "{}");
const ver = Number(st.stats_version) || 0;
if (ver && ver !== statsVersion) {
loadAll({ silent: true });
} else if (st.refreshing) {
setStatus("后台刷新统计中…");
}
} catch (_) {}
});
statsEventSource.onerror = function () {
closeStatsStream();
setStatus("SSE 断开,8s 后重连…");
statsReconnectTimer = setTimeout(function () {
connectStatsStream();
loadAll({ silent: true });
}, 8000);
};
}
function bind() {
const tabs = document.getElementById("stats-window-tabs");
if (tabs) {
tabs.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-window]");
if (!btn) return;
windowKey = btn.getAttribute("data-window") || "7d";
tabs.querySelectorAll(".stats-win-btn").forEach(function (b) {
b.classList.toggle("is-active", b === btn);
});
loadAll();
});
}
const showTotal = document.getElementById("stats-show-total");
if (showTotal) {
showTotal.addEventListener("change", function () {
if (curveData) drawCurve(curveData);
});
}
const picker = document.getElementById("stats-machine-picker");
if (picker) {
picker.addEventListener("change", function (ev) {
const input = ev.target.closest("input[data-machine-id]");
if (!input) return;
const id = String(input.getAttribute("data-machine-id") || "");
if (!selectedIds) selectedIds = new Set();
if (input.checked) selectedIds.add(id);
else selectedIds.delete(id);
saveSelectedIds();
if (curveData) drawCurve(curveData);
});
picker.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-act]");
if (!btn || !curveData) return;
const act = btn.getAttribute("data-act");
const ids = ((curveData && curveData.series) || []).map(function (s) {
return String(s.id);
});
if (act === "all") selectedIds = new Set(ids);
else if (act === "none") selectedIds = new Set();
saveSelectedIds();
renderMachinePicker(curveData);
drawCurve(curveData);
});
}
window.addEventListener("resize", function () {
if (curveData) drawCurve(curveData);
});
}
bind();
loadAll();
connectStatsStream();
})();