Improve control monitor cards: green when running, detail modal with positions.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -8,6 +8,8 @@ function pickStrategy(n: NodeCard) {
|
||||
(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,
|
||||
@@ -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<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);
|
||||
}
|
||||
|
||||
export default function MonitorPage() {
|
||||
const [nodes, setNodes] = useState<NodeCard[]>([]);
|
||||
const [err, setErr] = useState("");
|
||||
const [busy, setBusy] = useState<Record<number, string>>({});
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
const [pollSec, setPollSec] = useState(8);
|
||||
const [detailId, setDetailId] = useState<number | null>(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<string, unknown>[])
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="toolbar">
|
||||
@@ -120,9 +142,27 @@ export default function MonitorPage() {
|
||||
const s = pickStrategy(n);
|
||||
const running = s.running === true || s.running === 1;
|
||||
return (
|
||||
<article key={n.id} className={`node-card ${n.online ? "online" : "offline"}`}>
|
||||
<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">
|
||||
<label
|
||||
className="check"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onKeyDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(n.id)}
|
||||
@@ -171,14 +211,14 @@ export default function MonitorPage() {
|
||||
</dl>
|
||||
{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">
|
||||
<div className="node-actions" onClick={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
disabled={!!busy[n.id] || !n.token_configured}
|
||||
className={`btn ${running ? "btn-running" : ""}`}
|
||||
disabled={!!busy[n.id] || !n.token_configured || running}
|
||||
onClick={() => void act(n.id, "start")}
|
||||
>
|
||||
启动
|
||||
{running ? "运行中" : "启动"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@@ -212,6 +252,185 @@ export default function MonitorPage() {
|
||||
{!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>
|
||||
<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>
|
||||
{fmt(detail.strat.leverage, 1)}x /{" "}
|
||||
{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>
|
||||
{String(detail.strat.sizing_mode || "—")}
|
||||
{detail.strat.risk_last_k != null
|
||||
? ` · k=${fmt(detail.strat.risk_last_k, 2)}`
|
||||
: ""}
|
||||
</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>
|
||||
<p className="meta">
|
||||
状态:{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)}`
|
||||
: ""}
|
||||
</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>{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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user