Add fleet stats table with funds, fees, loss streak, and totals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-08-02 12:25:30 +08:00
parent 5982cb7414
commit 8745d8e6d1
5 changed files with 298 additions and 2 deletions
+170 -2
View File
@@ -76,6 +76,9 @@ type NodeStats = {
fees_option?: number;
total_fees: number;
total_slip?: number;
latest_funds?: number;
max_single_loss?: number;
loss_streak?: number;
close_reasons: Record<string, number>;
equity_curve: {
group_id: string;
@@ -84,6 +87,21 @@ type NodeStats = {
}[];
};
type FleetStatsRow = {
id: number;
name: string;
ok: boolean;
error?: string | null;
latest_funds?: number | null;
groups?: number | null;
fees_perp?: number | null;
fees_option?: number | null;
total_fees?: number | null;
max_single_loss?: number | null;
loss_streak?: number | null;
total_pnl?: number | null;
};
const CLOSE_REASON_ZH: Record<string, string> = {
fixed_usdt: "固定净盈利达标·双腿全平",
premium_multiple: "权利金倍数达标·双腿全平",
@@ -235,12 +253,28 @@ export default function MonitorPage() {
const [detailStatsLoading, setDetailStatsLoading] = useState(false);
const [equityPage, setEquityPage] = useState(0);
const EQUITY_PAGE_SIZE = 5;
const [fleetStats, setFleetStats] = useState<FleetStatsRow[]>([]);
const [fleetStatsErr, setFleetStatsErr] = useState("");
const [fleetStatsLoading, setFleetStatsLoading] = useState(false);
const applyNodes = useCallback((list: NodeCard[]) => {
cachedNodes = list;
setNodes(list);
}, []);
const refreshStats = useCallback(async () => {
setFleetStatsLoading(true);
try {
const r = await apiFetch<{ nodes: FleetStatsRow[] }>("/api/nodes/stats/all");
setFleetStats(r.nodes || []);
setFleetStatsErr("");
} catch (ex) {
setFleetStatsErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setFleetStatsLoading(false);
}
}, []);
useEffect(() => {
if (detailId == null) {
setDetailStats(null);
@@ -279,7 +313,14 @@ export default function MonitorPage() {
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}, [applyNodes]);
void refreshStats();
}, [applyNodes, refreshStats]);
useEffect(() => {
void refreshStats();
const id = window.setInterval(() => void refreshStats(), 60_000);
return () => window.clearInterval(id);
}, [refreshStats]);
useEffect(() => {
apiFetch<{ sse_interval_sec?: number }>("/api/auth/me")
@@ -546,7 +587,14 @@ export default function MonitorPage() {
<thead>
<tr>
<th className="col-check">
<span className="sr-only"></span>
<input
type="checkbox"
checked={allSelected}
disabled={!updatableIds.length}
onChange={toggleSelectAll}
aria-label="全选"
title="全选已配对机器"
/>
</th>
<th className="col-status"></th>
<th></th>
@@ -670,6 +718,126 @@ export default function MonitorPage() {
<p className="meta"> Token</p>
)}
<section className="stats-section">
<div className="toolbar stats-toolbar">
<h2></h2>
<div className="toolbar-actions">
<button
type="button"
className="btn ghost"
disabled={fleetStatsLoading}
onClick={() => void refreshStats()}
>
{fleetStatsLoading ? "加载中…" : "刷新统计"}
</button>
</div>
</div>
{fleetStatsErr ? <div className="err soft">{fleetStatsErr}</div> : null}
{fleetStats.length ? (
<div className="monitor-table-wrap">
<table className="monitor-table stats-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{fleetStats.map((row) => (
<tr key={row.id}>
<td>
<strong className="machine-name">{row.name}</strong>
{!row.ok && row.error ? (
<div className="meta" title={row.error}>
</div>
) : null}
</td>
<td className="mono">
{row.latest_funds != null
? fmt(row.latest_funds, 2)
: "—"}
</td>
<td className="mono">
{row.groups != null ? row.groups : "—"}
</td>
<td className="mono">
{row.fees_perp != null ? fmt(row.fees_perp, 4) : "—"}
</td>
<td className="mono">
{row.fees_option != null
? fmt(row.fees_option, 4)
: "—"}
</td>
<td className="mono">
{row.total_fees != null ? fmt(row.total_fees, 4) : "—"}
</td>
<td className={`mono ${pnlClass(row.max_single_loss)}`}>
{row.max_single_loss != null
? fmt(row.max_single_loss, 2)
: "—"}
</td>
<td className="mono">
{row.loss_streak != null ? row.loss_streak : "—"}
</td>
<td className={`mono ${pnlClass(row.total_pnl)}`}>
{row.total_pnl != null ? fmt(row.total_pnl, 2) : "—"}
</td>
</tr>
))}
</tbody>
<tfoot>
{(() => {
const okRows = fleetStats.filter((r) => r.ok);
const sum = (key: keyof FleetStatsRow) =>
okRows.reduce((a, r) => a + (Number(r[key]) || 0), 0);
const maxLoss = okRows.length
? Math.min(
0,
...okRows.map((r) => Number(r.max_single_loss) || 0),
)
: 0;
return (
<tr className="stats-total-row">
<td>
<strong></strong>
<span className="meta">
{" "}
({okRows.length}/{fleetStats.length})
</span>
</td>
<td className="mono">{fmt(sum("latest_funds"), 2)}</td>
<td className="mono">{sum("groups")}</td>
<td className="mono">{fmt(sum("fees_perp"), 4)}</td>
<td className="mono">{fmt(sum("fees_option"), 4)}</td>
<td className="mono">{fmt(sum("total_fees"), 4)}</td>
<td className={`mono ${pnlClass(maxLoss)}`}>
{fmt(maxLoss, 2)}
</td>
<td className="mono"></td>
<td className={`mono ${pnlClass(sum("total_pnl"))}`}>
{fmt(sum("total_pnl"), 2)}
</td>
</tr>
);
})()}
</tfoot>
</table>
</div>
) : (
<p className="meta">
{fleetStatsLoading ? "正在拉取各策略机统计…" : "暂无统计数据"}
</p>
)}
</section>
{detailNode && detail ? (
<div
className="modal-backdrop"
+15
View File
@@ -407,6 +407,21 @@ input {
justify-content: center;
}
.stats-section {
margin-top: 28px;
}
.stats-toolbar {
margin-bottom: 10px;
}
.stats-table tfoot .stats-total-row td {
border-top: 1px solid rgba(0, 229, 255, 0.35);
background: rgba(0, 40, 60, 0.45);
font-weight: 650;
color: var(--accent);
}
.btn-sm {
padding: 4px 8px;
font-size: 0.78rem;