Files
eth_hedge_sim/control/frontend/src/pages/Monitor.tsx
T
2026-08-02 09:49:27 +08:00

870 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from "react";
import { apiFetch, clearSession, getToken, type NodeCard } from "../api";
function pickStrategy(n: NodeCard) {
const fleet = (n.fleet || {}) as Record<string, unknown>;
const health = (n.health || {}) as Record<string, unknown>;
const strat =
(fleet.strategy as Record<string, unknown> | undefined) ||
(health.strategy as Record<string, unknown> | undefined) ||
{};
const position =
(fleet.position as Record<string, unknown> | undefined) || {};
return {
mode: String(fleet.mode || health.mode || "-"),
running: strat.running,
phase: String(strat.phase ?? "-"),
rounds: strat.rounds_done,
market: fleet.market_connected ?? health.market_connected,
exchange: String(fleet.exchange || health.exchange || "-"),
strat,
position,
pair: fleet.pair as Record<string, unknown> | null | undefined,
index_px: fleet.index_px,
};
}
function fmt(v: unknown, digits = 4): string {
if (v == null || v === "") return "—";
const n = Number(v);
if (!Number.isFinite(n)) return String(v);
return n.toFixed(digits);
}
function pnlClass(v: unknown): string {
const n = Number(v);
if (!Number.isFinite(n) || n === 0) return "pnl-flat";
return n > 0 ? "pnl-pos" : "pnl-neg";
}
const OPEN_STATUSES = new Set([
"open",
"half_open",
"option_closed_perp_pending",
"opening",
]);
function hasOpenPosition(position: Record<string, unknown>): boolean {
if (position.has_position === true) return true;
const st = String(position.status || "").toLowerCase();
return OPEN_STATUSES.has(st);
}
function unitLabel(v: unknown): string {
const n = Number(v);
if (!Number.isFinite(n)) return "—";
if (Number.isInteger(n)) return String(n);
return n.toFixed(2).replace(/\.?0+$/, "");
}
function leveragePair(strat: Record<string, unknown>): string {
const lev = Number(strat.leverage);
const opt = Number(strat.min_option_leverage);
const a = Number.isFinite(lev) ? `${Math.round(lev)}x` : "—";
const b = Number.isFinite(opt) ? `${Math.round(opt)}x` : "—";
return `${a}/${b}`;
}
type NodeStats = {
mode?: string;
show_slip?: boolean;
groups: number;
wins: number;
win_rate: number;
total_pnl: number;
fees_perp?: number;
fees_option?: number;
total_fees: number;
total_slip?: number;
close_reasons: Record<string, number>;
equity_curve: {
group_id: string;
realized_pnl: number;
close_at_ms?: number | null;
}[];
};
const CLOSE_REASON_ZH: Record<string, string> = {
fixed_usdt: "固定净盈利达标·双腿全平",
premium_multiple: "权利金倍数达标·双腿全平",
target_perp_only: "净盈利达标·只平永续(期权归档)",
expiry: "到期结算",
emergency: "紧急全平",
manual: "手动平仓",
liquidity_retry: "等待流动性后全平",
unknown: "未知",
};
function closeReasonZh(v: string): string {
return CLOSE_REASON_ZH[v] || v;
}
type RiskLines = {
riskBased: boolean;
sizing: string;
lossPct: string | null;
exit: string;
openRatio: string | null;
leverage: string;
};
function riskLines(strat: Record<string, unknown>): RiskLines {
const riskBased =
strat.sizing_mode === "risk_based" || strat.risk_based === true;
const exitMode = String(strat.exit_mode || "fixed_usdt");
let exit: string;
if (riskBased) {
// 必须读策略机 risk_exit_unit,禁止写死 15
exit =
strat.risk_exit_unit != null && Number.isFinite(Number(strat.risk_exit_unit))
? `基数${unitLabel(strat.risk_exit_unit)}`
: "基数—";
} else if (exitMode === "premium_multiple") {
exit =
strat.premium_exit_multiple != null
? `权利金×${fmt(strat.premium_exit_multiple, 2)}`
: "权利金×—";
} else {
const t = strat.net_profit_target ?? strat.exit_target_usdt;
exit =
t != null && Number.isFinite(Number(t))
? `固定 ${fmt(t, 2)}U`
: "固定 —";
}
// 以损定仓必显风险比例(risk_loss_pct);手动隐藏
let lossPct: string | null = null;
if (riskBased) {
const lossN = Number(strat.risk_loss_pct);
lossPct = Number.isFinite(lossN)
? `${fmt(lossN, lossN % 1 === 0 ? 0 : 2)}%`
: "—";
}
const openRatio = riskBased
? `${unitLabel(strat.risk_perp_unit)}:${unitLabel(strat.risk_option_unit)}`
: null;
return {
riskBased,
sizing: riskBased ? "以损定仓" : "手动仓位",
lossPct,
exit,
openRatio,
leverage: leveragePair(strat),
};
}
function RiskParamsBox({ strat }: { strat: Record<string, unknown> }) {
const r = riskLines(strat);
return (
<div className="risk-box">
<div className="risk-box-title"></div>
<dl className="kv risk-kv">
<div>
<dt></dt>
<dd>{r.sizing}</dd>
</div>
{r.lossPct != null ? (
<div>
<dt></dt>
<dd>{r.lossPct}</dd>
</div>
) : null}
<div>
<dt></dt>
<dd>{r.exit}</dd>
</div>
{r.openRatio ? (
<div>
<dt></dt>
<dd>{r.openRatio}</dd>
</div>
) : null}
<div>
<dt></dt>
<dd>{r.leverage}</dd>
</div>
</dl>
</div>
);
}
/** 页面级快照:整页刷新前保留上次卡片,避免空白等待 */
let cachedNodes: NodeCard[] = [];
let cachedSseSec = 1;
export default function MonitorPage() {
const [nodes, setNodes] = useState<NodeCard[]>(() => cachedNodes);
const [err, setErr] = useState("");
const [busy, setBusy] = useState<Record<number, string>>({});
const [selected, setSelected] = useState<Set<number>>(new Set());
const [sseSec, setSseSec] = useState(cachedSseSec);
const [liveState, setLiveState] = useState<"connecting" | "live" | "retry">(
cachedNodes.length ? "live" : "connecting",
);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailStats, setDetailStats] = useState<NodeStats | null>(null);
const [detailStatsErr, setDetailStatsErr] = useState("");
const [detailStatsLoading, setDetailStatsLoading] = useState(false);
const applyNodes = useCallback((list: NodeCard[]) => {
cachedNodes = list;
setNodes(list);
}, []);
useEffect(() => {
if (detailId == null) {
setDetailStats(null);
setDetailStatsErr("");
setDetailStatsLoading(false);
return;
}
let cancelled = false;
setDetailStats(null);
setDetailStatsErr("");
setDetailStatsLoading(true);
apiFetch<NodeStats>(`/api/nodes/${detailId}/stats`)
.then((s) => {
if (!cancelled) setDetailStats(s);
})
.catch((ex) => {
if (!cancelled) {
setDetailStatsErr(ex instanceof Error ? ex.message : String(ex));
}
})
.finally(() => {
if (!cancelled) setDetailStatsLoading(false);
});
return () => {
cancelled = true;
};
}, [detailId]);
const refresh = useCallback(async () => {
try {
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/status/all");
applyNodes(r.nodes || []);
setErr("");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}, [applyNodes]);
useEffect(() => {
apiFetch<{ sse_interval_sec?: number }>("/api/auth/me")
.then((m) => {
if (m.sse_interval_sec && Number(m.sse_interval_sec) > 0) {
const sec = Number(m.sse_interval_sec);
cachedSseSec = sec;
setSseSec(sec);
}
})
.catch(() => undefined);
}, []);
useEffect(() => {
let cancelled = false;
let retryMs = 1000;
let timer: number | undefined;
const ac = new AbortController();
// 无缓存时先拉一次;有缓存则等 SSE,避免切页/重挂载双倍打满策略机
if (!cachedNodes.length) {
void refresh();
}
async function runStream() {
while (!cancelled) {
setLiveState(retryMs > 1000 ? "retry" : "connecting");
try {
const token = getToken();
const headers = new Headers({ Accept: "text/event-stream" });
if (token) headers.set("Authorization", `Bearer ${token}`);
const res = await fetch("/api/nodes/status/stream", {
headers,
signal: ac.signal,
});
if (res.status === 401) {
clearSession();
setErr("登录已失效,请重新登录");
setLiveState("retry");
break;
}
if (!res.ok || !res.body) {
throw new Error(`SSE HTTP ${res.status}`);
}
setLiveState("live");
setErr("");
retryMs = 1000;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "";
let eventName = "message";
while (!cancelled) {
const { done, value } = await reader.read();
if (done) break;
buf += decoder.decode(value, { stream: true });
const parts = buf.split("\n");
buf = parts.pop() || "";
for (const rawLine of parts) {
const line = rawLine.replace(/\r$/, "");
if (line.startsWith("event:")) {
eventName = line.slice(6).trim() || "message";
continue;
}
if (line.startsWith("data:")) {
const data = line.slice(5).trim();
if (!data) continue;
try {
const parsed = JSON.parse(data) as {
nodes?: NodeCard[];
detail?: string;
};
if (eventName === "error") {
setErr(parsed.detail || "SSE 错误");
} else if (parsed.nodes) {
applyNodes(parsed.nodes);
setErr("");
}
} catch {
/* ignore malformed chunk */
}
eventName = "message";
continue;
}
if (line === "") {
eventName = "message";
}
}
}
} catch (ex) {
if (cancelled || (ex instanceof DOMException && ex.name === "AbortError")) {
break;
}
setLiveState("retry");
setErr(ex instanceof Error ? ex.message : String(ex));
}
if (cancelled) break;
await new Promise<void>((resolve) => {
timer = window.setTimeout(() => resolve(), retryMs);
});
retryMs = Math.min(retryMs * 2, 15000);
}
}
void runStream();
return () => {
cancelled = true;
ac.abort();
if (timer != null) window.clearTimeout(timer);
};
}, [refresh, applyNodes]);
async function act(id: number, action: "start" | "pause" | "update" | "login") {
if (action === "update") {
const node = nodes.find((x) => x.id === id);
const name = node?.name || `#${id}`;
const ok = window.confirm(
`确认更新「${name}」代码?\n\n将 git pull 并 reload 策略进程,短暂中断 API;不会自动启动策略。\n请勿误点。`,
);
if (!ok) return;
}
setBusy((b) => ({ ...b, [id]: action }));
setErr("");
try {
if (action === "login") {
const r = await apiFetch<{ url: string }>(`/api/nodes/${id}/login-url`, {
method: "POST",
});
window.open(r.url, "_blank", "noopener,noreferrer");
} else {
await apiFetch(`/api/nodes/${id}/${action === "pause" ? "pause" : action}`, {
method: "POST",
});
await refresh();
}
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setBusy((b) => {
const n = { ...b };
delete n[id];
return n;
});
}
}
async function batchUpdate() {
const ids = [...selected];
if (!ids.length) return;
const names = nodes
.filter((n) => ids.includes(n.id))
.map((n) => n.name)
.join("、");
const ok = window.confirm(
`确认批量更新 ${ids.length} 台策略机?\n\n${names}\n\n将分别 git pull 并 reload,短暂中断;不会自动启动策略。`,
);
if (!ok) return;
setErr("");
try {
await apiFetch("/api/nodes/update-batch", {
method: "POST",
body: JSON.stringify({ ids }),
});
await refresh();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
function toggle(id: number) {
setSelected((prev) => {
const n = new Set(prev);
if (n.has(id)) n.delete(id);
else n.add(id);
return n;
});
}
const detailNode = detailId != null ? nodes.find((x) => x.id === detailId) : null;
const detail = detailNode ? pickStrategy(detailNode) : null;
const detailRunning =
detail != null && (detail.running === true || detail.running === 1);
const detailLegs = Array.isArray(detail?.position?.legs)
? (detail!.position.legs as Record<string, unknown>[])
: [];
return (
<div>
<div className="toolbar">
<h2></h2>
<div className="toolbar-actions">
<span
className={`live-pill ${
liveState === "live" ? "ok" : liveState === "retry" ? "bad" : ""
}`}
title="中控 SSE 秒级推送"
>
{liveState === "live"
? `实时 · ${sseSec}s`
: liveState === "retry"
? "重连中…"
: "连接中…"}
</span>
<button type="button" className="btn ghost" onClick={() => void refresh()}>
</button>
<button
type="button"
className="btn"
disabled={!selected.size}
onClick={() => void batchUpdate()}
>
({selected.size})
</button>
</div>
</div>
{err ? <div className="err">{err}</div> : null}
<div className="card-grid">
{nodes.map((n) => {
const s = pickStrategy(n);
const running = s.running === true || s.running === 1;
const inPos = hasOpenPosition(s.position);
return (
<article
key={n.id}
className={`node-card ${n.online ? "online" : "offline"} ${
running ? "running" : ""
}`}
onClick={() => setDetailId(n.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setDetailId(n.id);
}
}}
>
<header className="node-card-head">
<label
className="check"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={selected.has(n.id)}
onChange={() => toggle(n.id)}
/>
<strong>{n.name}</strong>
</label>
<span className={`pill ${n.online ? "ok" : "bad"}`}>
{n.online ? "在线" : "离线"}
</span>
</header>
<div className="node-meta mono">{n.base_url}</div>
<dl className="kv">
<div>
<dt></dt>
<dd>{s.mode}</dd>
</div>
<div>
<dt></dt>
<dd>{s.exchange}</dd>
</div>
<div>
<dt></dt>
<dd>
{running ? "运行中" : "已停"} · {s.phase}
</dd>
</div>
<div>
<dt></dt>
<dd className={inPos ? "pnl-pos" : "pnl-neg"}>
{inPos ? "持仓中" : "无持仓"}
</dd>
</div>
<div>
<dt></dt>
<dd>{s.rounds ?? "-"}</dd>
</div>
<div>
<dt></dt>
<dd>{s.market ? "已连接" : "断开"}</dd>
</div>
<div>
<dt>Token</dt>
<dd>
{n.token_configured
? n.fleet_ok === false
? "配对失败"
: "已配对"
: "未配对"}
</dd>
</div>
</dl>
<RiskParamsBox strat={s.strat} />
{n.fleet_error ? <div className="err soft">{n.fleet_error}</div> : null}
{n.error ? <div className="err soft">{n.error}</div> : null}
<div className="node-actions" onClick={(e) => e.stopPropagation()}>
<button
type="button"
className={`btn ${running ? "btn-running" : ""}`}
disabled={!!busy[n.id] || !n.token_configured || running}
onClick={() => void act(n.id, "start")}
>
{running ? "运行中" : "启动"}
</button>
<button
type="button"
className="btn ghost"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "pause")}
>
</button>
<button
type="button"
className="btn"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "login")}
>
</button>
<button
type="button"
className="btn ghost"
disabled={!!busy[n.id] || !n.token_configured}
onClick={() => void act(n.id, "update")}
>
</button>
</div>
</article>
);
})}
</div>
{!nodes.length ? (
<p className="meta"> Token</p>
) : null}
{detailNode && detail ? (
<div
className="modal-backdrop"
onClick={() => setDetailId(null)}
role="presentation"
>
<div
className={`modal-panel ${detailRunning ? "running" : ""}`}
onClick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label={`${detailNode.name} 详情`}
>
<header className="modal-head">
<div>
<h3>{detailNode.name}</h3>
<div className="mono meta">{detailNode.base_url}</div>
</div>
<button
type="button"
className="btn ghost"
onClick={() => setDetailId(null)}
>
</button>
</header>
<section className="modal-section">
<h4></h4>
<RiskParamsBox strat={detail.strat} />
<dl className="kv detail-kv">
<div>
<dt></dt>
<dd>
{detailRunning ? "运行中" : "已停"} · {detail.phase}
</dd>
</div>
<div>
<dt> / </dt>
<dd>
{detail.mode} / {detail.exchange}
</dd>
</div>
<div>
<dt></dt>
<dd>{detail.rounds ?? "—"}</dd>
</div>
<div>
<dt> ID</dt>
<dd className="mono">
{String(detail.strat.group_id || detail.position.group_id || "—")}
</dd>
</div>
<div>
<dt></dt>
<dd>{String(detail.strat.perp_margin_mode || "—")}</dd>
</div>
<div>
<dt> /</dt>
<dd>
{fmt(detail.strat.perp_qty_eth, 4)} /{" "}
{fmt(detail.strat.option_qty_eth, 4)} ETH
</dd>
</div>
<div>
<dt></dt>
<dd>{fmt(detail.strat.exit_target_usdt, 2)} USDT</dd>
</div>
<div>
<dt></dt>
<dd>{fmt(detail.index_px, 2)}</dd>
</div>
<div>
<dt></dt>
<dd className="mono">
{detail.pair
? `${detail.pair.expiry_ymd || "?"} @ ${detail.pair.strike ?? "?"}`
: "—"}
</dd>
</div>
{detail.strat.last_error ? (
<div className="span-2">
<dt></dt>
<dd className="err soft">{String(detail.strat.last_error)}</dd>
</div>
) : null}
</dl>
</section>
<section className="modal-section">
<h4></h4>
{detailStatsLoading ? (
<p className="meta"></p>
) : null}
{detailStatsErr ? (
<p className="err soft">{detailStatsErr}</p>
) : null}
{detailStats ? (
<>
<dl className="kv detail-kv">
<div>
<dt> / </dt>
<dd className="mono">
{detailStats.groups} /{" "}
{(detailStats.win_rate * 100).toFixed(1)}%
</dd>
</div>
<div>
<dt></dt>
<dd className={`mono ${pnlClass(detailStats.total_pnl)}`}>
{fmt(detailStats.total_pnl, 2)} USDT
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.fees_perp ?? 0, 4)}
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.fees_option ?? 0, 4)}
</dd>
</div>
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.total_fees, 4)}
</dd>
</div>
{(detailStats.show_slip ?? detailStats.mode !== "LIVE") ? (
<div>
<dt></dt>
<dd className="mono">
{fmt(detailStats.total_slip ?? 0, 4)}
</dd>
</div>
) : null}
<div className="span-2">
<dt></dt>
<dd className="mono">
{Object.keys(detailStats.close_reasons || {}).length ===
0
? "—"
: Object.entries(detailStats.close_reasons)
.sort((a, b) => b[1] - a[1])
.map(([k, n]) => (
<div key={k}>
{closeReasonZh(k)} × {n}
</div>
))}
</dd>
</div>
</dl>
{detailStats.equity_curve?.length ? (
<>
<h4 style={{ marginTop: "0.75rem" }}></h4>
<div className="legs-table-wrap">
<table className="legs-table">
<thead>
<tr>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{[...detailStats.equity_curve]
.reverse()
.slice(0, 30)
.map((x) => (
<tr key={x.group_id}>
<td className="mono">{x.group_id}</td>
<td className={pnlClass(x.realized_pnl)}>
{fmt(x.realized_pnl, 2)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{detailStats.equity_curve.length > 30 ? (
<p className="meta">
30 {detailStats.equity_curve.length}
</p>
) : null}
</>
) : (
<p className="meta"></p>
)}
</>
) : null}
</section>
<section className="modal-section">
<h4></h4>
<p className="meta">
{String(detail.position.status || "flat")}
{detail.position.net_pnl != null ? (
<>
{" · 净浮盈 "}
<span className={pnlClass(detail.position.net_pnl)}>
{fmt(detail.position.net_pnl, 2)} USDT
</span>
</>
) : null}
{detail.position.expiry_ymd
? ` · 到期 ${String(detail.position.expiry_ymd)} @ ${fmt(detail.position.strike, 0)}`
: ""}
</p>
{detailLegs.length ? (
<div className="legs-table-wrap">
<table className="legs-table">
<thead>
<tr>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
</tr>
</thead>
<tbody>
{detailLegs.map((lg, i) => (
<tr key={`${lg.inst_id || i}`}>
<td>{String(lg.kind || "—")}</td>
<td>{String(lg.side || "—")}</td>
<td className="mono">{String(lg.inst_id || "—")}</td>
<td>{fmt(lg.qty, 4)}</td>
<td>{fmt(lg.avg_px, 4)}</td>
<td>{fmt(lg.mark_px, 4)}</td>
<td className={pnlClass(lg.upl)}>{fmt(lg.upl, 2)}</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<p className="meta"></p>
)}
</section>
<div className="node-actions">
<button
type="button"
className={`btn ${detailRunning ? "btn-running" : ""}`}
disabled={
!!busy[detailNode.id] ||
!detailNode.token_configured ||
detailRunning
}
onClick={() => void act(detailNode.id, "start")}
>
{detailRunning ? "运行中" : "启动"}
</button>
<button
type="button"
className="btn ghost"
disabled={!!busy[detailNode.id] || !detailNode.token_configured}
onClick={() => void act(detailNode.id, "pause")}
>
</button>
<button
type="button"
className="btn"
disabled={!!busy[detailNode.id] || !detailNode.token_configured}
onClick={() => void act(detailNode.id, "login")}
>
</button>
</div>
</div>
</div>
) : null}
</div>
);
}