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); } 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 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; 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; lossPct: string | null; exit: string; openRatio: string | null; 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` : "固定 —"; } // 以损定仓必显风险比例(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 }) { 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 [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 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(`/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() { 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[]) : []; return (

监控区

{liveState === "live" ? `实时 · ${sseSec}s` : liveState === "retry" ? "重连中…" : "连接中…"}
{err ?
{err}
: null}
{nodes.map((n) => { const s = pickStrategy(n); const running = s.running === true || s.running === 1; const inPos = hasOpenPosition(s.position); return (
setDetailId(n.id)} role="button" tabIndex={0} onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); setDetailId(n.id); } }} >
{n.online ? "在线" : "离线"}
{n.base_url}
模式
{s.mode}
交易所
{s.exchange}
策略
{running ? "运行中" : "已停"} · {s.phase}
持仓
{inPos ? "持仓中" : "无持仓"}
轮次
{s.rounds ?? "-"}
行情
{s.market ? "已连接" : "断开"}
Token
{n.token_configured ? n.fleet_ok === false ? "配对失败" : "已配对" : "未配对"}
{n.fleet_error ?
{n.fleet_error}
: null} {n.error ?
{n.error}
: null}
e.stopPropagation()}>
); })}
{!nodes.length ? (

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

) : null} {detailNode && detail ? (
setDetailId(null)} role="presentation" >
e.stopPropagation()} role="dialog" aria-modal="true" aria-label={`${detailNode.name} 详情`} >

{detailNode.name}

{detailNode.base_url}

策略详情

状态
{detailRunning ? "运行中" : "已停"} · {detail.phase}
模式 / 交易所
{detail.mode} / {detail.exchange}
轮次
{detail.rounds ?? "—"}
组 ID
{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
指数
{fmt(detail.index_px, 2)}
合约对
{detail.pair ? `${detail.pair.expiry_ymd || "?"} @ ${detail.pair.strike ?? "?"}` : "—"}
{detail.strat.last_error ? (
最近错误
{String(detail.strat.last_error)}
) : null}

整体统计

{detailStatsLoading ? (

正在从策略机拉取统计…

) : null} {detailStatsErr ? (

{detailStatsErr}

) : null} {detailStats ? ( <>
组数 / 胜率
{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)}
{(detailStats.show_slip ?? detailStats.mode !== "LIVE") ? (
滑点合计
{fmt(detailStats.total_slip ?? 0, 4)}
) : null}
平仓原因
{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}
))}
{detailStats.equity_curve?.length ? ( <>

按组盈亏

{[...detailStats.equity_curve] .reverse() .slice(0, 30) .map((x) => ( ))}
盈亏
{x.group_id} {fmt(x.realized_pnl, 2)}
{detailStats.equity_curve.length > 30 ? (

仅显示最近 30 组(共 {detailStats.equity_curve.length})

) : null} ) : (

暂无已平仓组

)} ) : null}

持仓

状态:{String(detail.position.status || "flat")} {detail.position.net_pnl != null ? ( <> {" · 净浮盈 "} {fmt(detail.position.net_pnl, 2)} USDT ) : null} {detail.position.expiry_ymd ? ` · 到期 ${String(detail.position.expiry_ymd)} @ ${fmt(detail.position.strike, 0)}` : ""}

{detailLegs.length ? (
{detailLegs.map((lg, i) => ( ))}
方向 合约 数量 均价 标记 浮盈
{String(lg.kind || "—")} {String(lg.side || "—")} {String(lg.inst_id || "—")} {fmt(lg.qty, 4)} {fmt(lg.avg_px, 4)} {fmt(lg.mark_px, 4)} {fmt(lg.upl, 2)}
) : (

当前无持仓腿

)}
) : null}
); }