From a317822f7a558b5d61498e23d16ed072ee4ae5bd Mon Sep 17 00:00:00 2001 From: dekun Date: Thu, 30 Jul 2026 11:07:54 +0800 Subject: [PATCH] Improve control monitor cards: green when running, detail modal with positions. Co-authored-by: Cursor --- backend/app/api/fleet.py | 64 ++++++- control/frontend/src/pages/Monitor.tsx | 231 ++++++++++++++++++++++++- control/frontend/src/styles.css | 89 ++++++++++ 3 files changed, 377 insertions(+), 7 deletions(-) diff --git a/backend/app/api/fleet.py b/backend/app/api/fleet.py index 27bdb44..1ca26c3 100644 --- a/backend/app/api/fleet.py +++ b/backend/app/api/fleet.py @@ -167,6 +167,38 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di st = get_engine().state() except Exception: st = {} + pos = st.get("position") if isinstance(st.get("position"), dict) else {} + legs: list[dict] = [] + if pos.get("has_position") or str(pos.get("status") or "") in ( + "open", + "half_open", + "option_closed_perp_pending", + "opening", + ): + if pos.get("perp_side") or pos.get("perp_inst_id"): + legs.append( + { + "kind": "perp", + "side": pos.get("perp_side"), + "inst_id": pos.get("perp_inst_id"), + "qty": pos.get("perp_qty_eth"), + "avg_px": pos.get("perp_entry_px"), + "mark_px": pos.get("perp_mark_px"), + "upl": pos.get("perp_upl"), + } + ) + if pos.get("option_side") or pos.get("option_inst_id"): + legs.append( + { + "kind": "option", + "side": pos.get("option_side"), + "inst_id": pos.get("option_inst_id"), + "qty": pos.get("option_qty_eth"), + "avg_px": pos.get("option_entry_px"), + "mark_px": pos.get("option_mark_px"), + "upl": pos.get("option_upl"), + } + ) return { "ok": True, "mode": settings.mode, @@ -175,13 +207,43 @@ async def fleet_status(_tok: Annotated[str, Depends(require_fleet_token)]) -> di "sim": settings.is_sim, "market_connected": bool(snap.connected) if snap else False, "pair": snap.pair.to_dict() if snap and snap.pair else None, + "index_px": ( + pos.get("index_px") + if pos.get("index_px") is not None + else (getattr(snap, "index_px", None) if snap else None) + ), "updated_at_ms": snap.updated_at_ms if snap else None, "strategy": { "running": st.get("running"), "phase": st.get("phase"), "rounds_done": st.get("rounds_done"), "last_error": st.get("last_error"), - "group_id": st.get("group_id"), + "group_id": pos.get("group_id"), + "rest_left_sec": st.get("rest_left_sec"), + "exit_mode": st.get("exit_mode"), + "exit_target_usdt": st.get("exit_target_usdt"), + "net_profit_target": st.get("net_profit_target"), + "leverage": st.get("leverage"), + "perp_margin_mode": st.get("perp_margin_mode"), + "perp_qty_eth": st.get("perp_qty_eth"), + "option_qty_eth": st.get("option_qty_eth"), + "sizing_mode": st.get("sizing_mode"), + "risk_last_k": st.get("risk_last_k"), + "risk_sizing_locked": st.get("risk_sizing_locked"), + }, + "position": { + "status": pos.get("status") or ("open" if pos.get("has_position") else "flat"), + "has_position": bool(pos.get("has_position")), + "group_id": pos.get("group_id"), + "open_at_ms": pos.get("open_at_ms"), + "initial_premium": pos.get("initial_premium"), + "exit_target_usdt": pos.get("exit_target_usdt"), + "net_pnl": pos.get("net_pnl"), + "perp_upl": pos.get("perp_upl"), + "option_upl": pos.get("option_upl"), + "strike": pos.get("strike"), + "expiry_ymd": pos.get("expiry_ymd"), + "legs": legs, }, "update": { "running": bool(_update_state.get("running")), diff --git a/control/frontend/src/pages/Monitor.tsx b/control/frontend/src/pages/Monitor.tsx index acbccb0..2f4ad52 100644 --- a/control/frontend/src/pages/Monitor.tsx +++ b/control/frontend/src/pages/Monitor.tsx @@ -8,6 +8,8 @@ function pickStrategy(n: NodeCard) { (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, @@ -15,15 +17,27 @@ function pickStrategy(n: NodeCard) { 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); +} + export default function MonitorPage() { const [nodes, setNodes] = useState([]); const [err, setErr] = useState(""); const [busy, setBusy] = useState>({}); const [selected, setSelected] = useState>(new Set()); const [pollSec, setPollSec] = useState(8); + const [detailId, setDetailId] = useState(null); const refresh = useCallback(async () => { try { @@ -96,6 +110,14 @@ export default function MonitorPage() { }); } + 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 (
@@ -120,9 +142,27 @@ export default function MonitorPage() { const s = pickStrategy(n); const running = s.running === true || s.running === 1; return ( -
+
setDetailId(n.id)} + role="button" + tabIndex={0} + onKeyDown={(e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setDetailId(n.id); + } + }} + >
-
+ +
+

策略详情

+
+
+
状态
+
+ {detailRunning ? "运行中" : "已停"} · {detail.phase} +
+
+
+
模式 / 交易所
+
+ {detail.mode} / {detail.exchange} +
+
+
+
轮次
+
{detail.rounds ?? "—"}
+
+
+
组 ID
+
+ {String(detail.strat.group_id || detail.position.group_id || "—")} +
+
+
+
杠杆 / 保证金
+
+ {fmt(detail.strat.leverage, 1)}x /{" "} + {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
+
+
+
定仓
+
+ {String(detail.strat.sizing_mode || "—")} + {detail.strat.risk_last_k != null + ? ` · k=${fmt(detail.strat.risk_last_k, 2)}` + : ""} +
+
+
+
指数
+
{fmt(detail.index_px, 2)}
+
+
+
合约对
+
+ {detail.pair + ? `${detail.pair.expiry_ymd || "?"} @ ${detail.pair.strike ?? "?"}` + : "—"} +
+
+ {detail.strat.last_error ? ( +
+
最近错误
+
{String(detail.strat.last_error)}
+
+ ) : null} +
+
+ +
+

持仓

+

+ 状态:{String(detail.position.status || "flat")} + {detail.position.net_pnl != null + ? ` · 净浮盈 ${fmt(detail.position.net_pnl, 2)} USDT` + : ""} + {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} ); } diff --git a/control/frontend/src/styles.css b/control/frontend/src/styles.css index 5a7250d..952c2a6 100644 --- a/control/frontend/src/styles.css +++ b/control/frontend/src/styles.css @@ -191,12 +191,29 @@ input { display: flex; flex-direction: column; gap: 10px; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; +} + +.node-card:hover { + border-color: #3d5a73; } .node-card.offline { opacity: 0.85; } +.node-card.running { + background: linear-gradient(160deg, rgba(46, 140, 90, 0.28), rgba(26, 34, 44, 0.95)); + border-color: rgba(60, 179, 113, 0.65); + box-shadow: 0 0 0 1px rgba(60, 179, 113, 0.15); +} + +.btn-running { + background: rgba(60, 179, 113, 0.85) !important; + cursor: default; +} + .node-card-head { display: flex; justify-content: space-between; @@ -297,3 +314,75 @@ td { margin: 12px 0; word-break: break-all; } + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(4, 8, 14, 0.72); + display: grid; + place-items: center; + padding: 16px; + z-index: 50; +} + +.modal-panel { + width: min(720px, 100%); + max-height: min(90vh, 900px); + overflow: auto; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 12px; + padding: 18px; + display: flex; + flex-direction: column; + gap: 14px; +} + +.modal-panel.running { + border-color: rgba(60, 179, 113, 0.65); + box-shadow: 0 0 0 1px rgba(60, 179, 113, 0.2); +} + +.modal-head { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px; +} + +.modal-head h3 { + margin: 0 0 4px; +} + +.modal-section h4 { + margin: 0 0 8px; + font-size: 0.95rem; +} + +.detail-kv { + grid-template-columns: 1fr 1fr; +} + +.detail-kv .span-2 { + grid-column: 1 / -1; +} + +.legs-table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: 8px; +} + +.legs-table { + width: 100%; + border-collapse: collapse; + font-size: 0.85rem; +} + +.legs-table th, +.legs-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--line); + text-align: left; + white-space: nowrap; +}