Improve control monitor cards: green when running, detail modal with positions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-30 11:07:54 +08:00
parent 08fe06d074
commit a317822f7a
3 changed files with 377 additions and 7 deletions
+63 -1
View File
@@ -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")),
+225 -6
View File
@@ -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>
);
}
+89
View File
@@ -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;
}