import { useCallback, useEffect, useState } from "react"; import { apiFetch, clearSession, getToken, type NodeCard } from "../api"; function pickStrategy(n: NodeCard) { const fleet = (n.fleet || {}) as Record; const health = (n.health || {}) as Record; const strat = (fleet.strategy as Record | undefined) || (health.strategy as Record | undefined) || {}; const position = (fleet.position as Record | 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 | 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); } /** 价格按交易所常见精度:永续/指数 0.01;期权权利金 0.1 */ function fmtExPx(kind: unknown, px: unknown): string { if (px == null || px === "") return "—"; const n = Number(px); if (!Number.isFinite(n)) return String(px); if (String(kind || "").toLowerCase() === "option") { return (Math.round(n * 10) / 10).toFixed(1); } return (Math.round(n * 100) / 100).toFixed(2); } 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): 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 { 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 ResidualRow = { key: string; nodeId: number; nodeName: string; group_id: string; option_inst_id: string; option_qty_eth: number | null; initial_premium: number | null; bid_px: number | null; current_premium: number | null; recovery_pct: number | null; liquidity_ok: boolean; }; function collectResiduals(nodes: NodeCard[]): ResidualRow[] { const out: ResidualRow[] = []; for (const n of nodes) { const fleet = (n.fleet || {}) as Record; const list = fleet.residuals; if (!Array.isArray(list)) continue; for (const raw of list) { if (!raw || typeof raw !== "object") continue; const r = raw as Record; const gid = String(r.group_id || ""); if (!gid) continue; const numOrNull = (v: unknown) => { if (v == null || v === "") return null; const x = Number(v); return Number.isFinite(x) ? x : null; }; out.push({ key: `${n.id}:${gid}`, nodeId: n.id, nodeName: n.name, group_id: gid, option_inst_id: String(r.option_inst_id || "—"), option_qty_eth: numOrNull(r.option_qty_eth), initial_premium: numOrNull(r.initial_premium), bid_px: numOrNull(r.bid_px), current_premium: numOrNull(r.current_premium), recovery_pct: numOrNull(r.recovery_pct), liquidity_ok: r.liquidity_ok === true, }); } } return out; } 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; latest_funds?: number; max_single_loss?: number; loss_streak?: number; close_reasons: Record; equity_curve: { group_id: string; realized_pnl: number; close_at_ms?: number | null; }[]; }; const CLOSE_REASON_ZH: Record = { 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; sizingShort: string; lossPct: string | null; exit: string; openRatio: string | null; /** 表格「风险/开仓」列:如 5%/1:2;手动为 —/名义比 */ riskOrOpen: string; leverage: string; }; function riskLines(strat: Record): 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` : "固定 —"; } 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)}` : `${unitLabel(strat.perp_qty_eth)}:${unitLabel(strat.option_qty_eth)}`; const riskOrOpen = `${lossPct || "—"}/${openRatio}`; return { riskBased, sizing: riskBased ? "以损定仓" : "手动仓位", sizingShort: riskBased ? "以损" : "手动", lossPct, exit, openRatio, riskOrOpen, leverage: leveragePair(strat), }; } function modeLabel(mode: string, strat: Record): string { const m = String(mode || "-").toUpperCase(); const short = m.includes("LIVE") ? "LIVE" : m.includes("SIM") ? "SIM" : m || "-"; return `${short}/${riskLines(strat).sizingShort}`; } function tokenDotClass(n: { token_configured?: boolean; fleet_ok?: boolean | null; }): string { if (!n.token_configured) return "dot muted"; if (n.fleet_ok === false) return "dot warn"; return "dot ok"; } function tokenDotTitle(n: { token_configured?: boolean; fleet_ok?: boolean | null; }): string { if (!n.token_configured) return "Token 未配对"; if (n.fleet_ok === false) return "Token 配对失败"; return "Token 已配对"; } function RiskParamsBox({ strat }: { strat: Record }) { const r = riskLines(strat); return (
风控参数
定仓
{r.sizing}
{r.lossPct != null ? (
风险比例
{r.lossPct}
) : null}
出场
{r.exit}
{r.openRatio ? (
开仓比例
{r.openRatio}
) : null}
杠杆
{r.leverage}
); } /** 页面级快照:整页刷新前保留上次卡片,避免空白等待 */ let cachedNodes: NodeCard[] = []; let cachedSseSec = 1; export default function MonitorPage() { const [nodes, setNodes] = useState(() => cachedNodes); const [err, setErr] = useState(""); const [busy, setBusy] = useState>({}); const [batchBusy, setBatchBusy] = useState(false); const [selected, setSelected] = useState>(new Set()); const [sseSec, setSseSec] = useState(cachedSseSec); const [liveState, setLiveState] = useState<"connecting" | "live" | "retry">( cachedNodes.length ? "live" : "connecting", ); const [detailId, setDetailId] = useState(null); const [detailStats, setDetailStats] = useState(null); const [detailStatsErr, setDetailStatsErr] = useState(""); const [detailStatsLoading, setDetailStatsLoading] = useState(false); const [equityPage, setEquityPage] = useState(0); const [residualBusy, setResidualBusy] = useState(""); const EQUITY_PAGE_SIZE = 5; const applyNodes = useCallback((list: NodeCard[]) => { cachedNodes = list; setNodes(list); }, []); useEffect(() => { if (detailId == null) { setDetailStats(null); setDetailStatsErr(""); setDetailStatsLoading(false); setEquityPage(0); return; } let cancelled = false; setDetailStats(null); setDetailStatsErr(""); setDetailStatsLoading(true); setEquityPage(0); apiFetch(`/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((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(idsOverride?: number[]) { const ids = idsOverride ?? [...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 updatableIds = nodes .filter((n) => n.token_configured) .map((n) => n.id); const allSelected = updatableIds.length > 0 && updatableIds.every((id) => selected.has(id)); function toggleSelectAll() { setSelected((prev) => { if (updatableIds.length === 0) return prev; if (updatableIds.every((id) => prev.has(id))) return new Set(); return new Set(updatableIds); }); } async function updateAll() { if (!updatableIds.length) { setErr("没有已配对 Token 的策略机可更新"); return; } setSelected(new Set(updatableIds)); await batchUpdate(updatableIds); } const startableIds = nodes .filter((n) => { if (!n.token_configured) return false; const s = pickStrategy(n); const running = s.running === true || s.running === 1; return !running; }) .map((n) => n.id); async function closeResidual(row: ResidualRow) { const ok = window.confirm( `确认平掉残留期权?\n\n机器:${row.nodeName}\n组:${row.group_id}\n合约:${row.option_inst_id}\n\n仅校验买一流动性,不要求权利金回收比例。`, ); if (!ok) return; setResidualBusy(row.key); setErr(""); try { await apiFetch(`/api/nodes/${row.nodeId}/residual/close`, { method: "POST", body: JSON.stringify({ group_id: row.group_id }), }); await refresh(); } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { setResidualBusy(""); } } async function startAll() { if (!startableIds.length) { setErr("没有可启动的策略机(需已配对且当前未运行)"); return; } const names = nodes .filter((n) => startableIds.includes(n.id)) .map((n) => n.name) .join("、"); const ok = window.confirm( `确认全部启动 ${startableIds.length} 台策略机?\n\n${names}\n\n将并行调用各机 Fleet 启动(已在运行的不会列入)。`, ); if (!ok) return; setErr(""); setBatchBusy(true); try { const r = await apiFetch<{ started?: number; total?: number; results?: { id: number; name?: string; ok: boolean; detail?: string }[]; }>("/api/nodes/start-batch", { method: "POST", body: JSON.stringify({ ids: startableIds }), }); await refresh(); const fails = (r.results || []).filter((x) => !x.ok); if (fails.length) { setErr( `启动完成 ${r.started ?? 0}/${r.total ?? startableIds.length};失败:` + fails .map((x) => `${x.name || x.id}${x.detail ? `(${x.detail})` : ""}`) .join(";"), ); } } catch (ex) { setErr(ex instanceof Error ? ex.message : String(ex)); } finally { setBatchBusy(false); } } 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[]) : []; const equityNewestFirst = detailStats?.equity_curve?.length ? [...detailStats.equity_curve].reverse() : []; const equityPageCount = Math.max( 1, Math.ceil(equityNewestFirst.length / EQUITY_PAGE_SIZE), ); const equityPageSafe = Math.min(equityPage, equityPageCount - 1); const equityPageRows = equityNewestFirst.slice( equityPageSafe * EQUITY_PAGE_SIZE, equityPageSafe * EQUITY_PAGE_SIZE + EQUITY_PAGE_SIZE, ); const residualRows = collectResiduals(nodes); return (

监控区

{liveState === "live" ? `实时 · ${sseSec}s` : liveState === "retry" ? "重连中…" : "连接中…"}
{err ?
{err}
: null} {nodes.length ? (
{nodes.map((n) => { const s = pickStrategy(n); const running = s.running === true || s.running === 1; const inPos = hasOpenPosition(s.position); const risk = riskLines(s.strat); return ( setDetailId(n.id)} > ); })}
状态 机器名字 模式 交易所 持仓 轮次 风险/开仓 出场 杠杆 操作
e.stopPropagation()} > toggle(n.id)} aria-label={`选择 ${n.name}`} />
{n.name} {modeLabel(s.mode, s.strat)} {s.exchange} {inPos ? "持仓中" : "无持仓"} {s.rounds ?? "—"} {risk.riskOrOpen} {risk.exit} {risk.leverage} e.stopPropagation()} >
) : (

暂无策略机。请到「系统设置」添加并生成 Token。

)} {residualRows.length ? ( <>

残留期权

自动平仓仍要求权利金≥设定比例;下方「平仓」仅验流动性
{residualRows.map((r) => ( ))}
机器名 组名 期权名 数量 开仓权利金 当前买一权利金 回收占比% 操作
{r.nodeName} {r.group_id} {r.option_inst_id} {fmt(r.option_qty_eth, 4)} {fmt(r.initial_premium, 2)} {fmt(r.current_premium, 2)} {r.recovery_pct == null ? "—" : fmt(r.recovery_pct, 1)}
) : null} {detailNode && detail ? (
setDetailId(null)} role="presentation" >
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={`${detailNode.name} 详情`} >

{detailNode.name}

{detailNode.base_url}

策略详情

{(() => { const r = riskLines(detail.strat); return (
{r.lossPct != null ? : null} {r.openRatio ? : null} {r.lossPct != null ? : null} {r.openRatio ? : null}
定仓风险比例出场开仓比例杠杆 状态 模式/交易所 轮次 组 ID 保证金 名义永续/期权 出场目标 指数 合约对
{r.sizing}{r.lossPct}{r.exit}{r.openRatio}{r.leverage} {detailRunning ? "运行中" : "已停"} · {detail.phase} {detail.mode} / {detail.exchange} {detail.rounds ?? "—"} {String( detail.strat.group_id || detail.position.group_id || "—", )} {String(detail.strat.perp_margin_mode || "—")} {fmt(detail.strat.perp_qty_eth, 4)} /{" "} {fmt(detail.strat.option_qty_eth, 4)} ETH {fmt(detail.strat.exit_target_usdt, 2)} USDT {fmtExPx("perp", detail.index_px)} {detail.pair ? `${detail.pair.expiry_ymd || "?"} @ ${ detail.pair.strike != null ? Math.round(Number(detail.pair.strike)) : "?" }` : "—"}
); })()} {detail.strat.last_error ? (

最近错误:{String(detail.strat.last_error)}

) : null}

整体统计

{detailStatsLoading ? (

正在从策略机拉取统计…

) : null} {detailStatsErr ? (

{detailStatsErr}

) : null} {detailStats ? (
{(detailStats.show_slip ?? detailStats.mode !== "LIVE") ? ( ) : null} {(detailStats.show_slip ?? detailStats.mode !== "LIVE") ? ( ) : null}
组数/胜率 总盈亏 永续手续费 期权手续费 手续费合计滑点合计平仓原因
{detailStats.groups} /{" "} {(detailStats.win_rate * 100).toFixed(1)}% {fmt(detailStats.total_pnl, 2)} USDT {fmt(detailStats.fees_perp ?? 0, 4)} {fmt(detailStats.fees_option ?? 0, 4)} {fmt(detailStats.total_fees, 4)} {fmt(detailStats.total_slip ?? 0, 4)} {Object.keys(detailStats.close_reasons || {}) .length === 0 ? "—" : Object.entries(detailStats.close_reasons) .sort((a, b) => b[1] - a[1]) .map(([k, n]) => `${closeReasonZh(k)}×${n}`) .join(" · ")}
) : !detailStatsLoading && !detailStatsErr ? (

暂无统计

) : null}

按组盈亏

{detailStatsLoading ? (

加载中…

) : equityNewestFirst.length ? ( <>
{equityPageRows.map((x) => ( ))}
盈亏
{x.group_id} {fmt(x.realized_pnl, 2)}
第 {equityPageSafe + 1}/{equityPageCount} 页 · 每页{" "} {EQUITY_PAGE_SIZE} 行 · 共 {equityNewestFirst.length} 组
) : (

暂无已平仓组

)}

持仓

状态:{String(detail.position.status || "flat")} {detail.position.net_pnl != null ? ( <> {" · 净浮盈 "} {fmt(detail.position.net_pnl, 2)} USDT ) : null} {detail.position.perp_margin != null ? ( <> {" · 保证金 "} {fmt(detail.position.perp_margin, 2)} U ) : null} {detail.position.initial_premium != null ? ( <> {" · 权利金 "} {fmt(detail.position.initial_premium, 2)} U ) : null} {detail.position.expiry_ymd ? ` · 到期 ${String(detail.position.expiry_ymd)} @ ${ detail.position.strike != null ? Math.round(Number(detail.position.strike)) : "—" }` : ""}

{detailLegs.length ? (
{detailLegs.map((lg, i) => ( ))}
方向 合约 数量 均价 标记 保证金 权利金 浮盈
{String(lg.kind || "—")} {String(lg.side || "—")} {String(lg.inst_id || "—")} {fmt(lg.qty, 2)} {fmtExPx(lg.kind, lg.avg_px)} {fmtExPx(lg.kind, lg.mark_px)} {lg.kind === "perp" ? fmt( lg.margin ?? detail.position.perp_margin, 2, ) : "—"} {lg.kind === "option" ? fmt( lg.premium ?? detail.position.initial_premium, 2, ) : "—"} {fmt(lg.upl, 2)}
) : (

当前无持仓腿

)}
) : null}
); }