a317822f7a
Co-authored-by: Cursor <cursoragent@cursor.com>
437 lines
15 KiB
TypeScript
437 lines
15 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
||
import { apiFetch, type NodeCard } from "../api";
|
||
|
||
function pickStrategy(n: NodeCard) {
|
||
const fleet = (n.fleet || {}) as Record<string, unknown>;
|
||
const health = (n.health || {}) as Record<string, unknown>;
|
||
const strat =
|
||
(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,
|
||
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<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 {
|
||
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/status/all");
|
||
setNodes(r.nodes || []);
|
||
setErr("");
|
||
} catch (ex) {
|
||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
apiFetch<{ poll_interval_sec?: number }>("/api/auth/me")
|
||
.then((m) => {
|
||
if (m.poll_interval_sec) setPollSec(m.poll_interval_sec);
|
||
})
|
||
.catch(() => undefined);
|
||
void refresh();
|
||
const id = window.setInterval(() => void refresh(), pollSec * 1000);
|
||
return () => window.clearInterval(id);
|
||
}, [refresh, pollSec]);
|
||
|
||
async function act(id: number, action: "start" | "pause" | "update" | "login") {
|
||
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;
|
||
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<string, unknown>[])
|
||
: [];
|
||
|
||
return (
|
||
<div>
|
||
<div className="toolbar">
|
||
<h2>监控区</h2>
|
||
<div className="toolbar-actions">
|
||
<button type="button" className="btn ghost" onClick={() => void refresh()}>
|
||
刷新
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
disabled={!selected.size}
|
||
onClick={() => void batchUpdate()}
|
||
>
|
||
勾选更新 ({selected.size})
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{err ? <div className="err">{err}</div> : null}
|
||
<div className="card-grid">
|
||
{nodes.map((n) => {
|
||
const s = pickStrategy(n);
|
||
const running = s.running === true || s.running === 1;
|
||
return (
|
||
<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"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={(e) => e.stopPropagation()}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={selected.has(n.id)}
|
||
onChange={() => toggle(n.id)}
|
||
/>
|
||
<strong>{n.name}</strong>
|
||
</label>
|
||
<span className={`pill ${n.online ? "ok" : "bad"}`}>
|
||
{n.online ? "在线" : "离线"}
|
||
</span>
|
||
</header>
|
||
<div className="node-meta mono">{n.base_url}</div>
|
||
<dl className="kv">
|
||
<div>
|
||
<dt>模式</dt>
|
||
<dd>{s.mode}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>交易所</dt>
|
||
<dd>{s.exchange}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>策略</dt>
|
||
<dd>
|
||
{running ? "运行中" : "已停"} · {s.phase}
|
||
</dd>
|
||
</div>
|
||
<div>
|
||
<dt>轮次</dt>
|
||
<dd>{s.rounds ?? "-"}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>行情</dt>
|
||
<dd>{s.market ? "已连接" : "断开"}</dd>
|
||
</div>
|
||
<div>
|
||
<dt>Token</dt>
|
||
<dd>
|
||
{n.token_configured
|
||
? n.fleet_ok === false
|
||
? "配对失败"
|
||
: "已配对"
|
||
: "未配对"}
|
||
</dd>
|
||
</div>
|
||
</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" onClick={(e) => e.stopPropagation()}>
|
||
<button
|
||
type="button"
|
||
className={`btn ${running ? "btn-running" : ""}`}
|
||
disabled={!!busy[n.id] || !n.token_configured || running}
|
||
onClick={() => void act(n.id, "start")}
|
||
>
|
||
{running ? "运行中" : "启动"}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn ghost"
|
||
disabled={!!busy[n.id] || !n.token_configured}
|
||
onClick={() => void act(n.id, "pause")}
|
||
>
|
||
停止
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn"
|
||
disabled={!!busy[n.id] || !n.token_configured}
|
||
onClick={() => void act(n.id, "login")}
|
||
>
|
||
登录策略机
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="btn ghost"
|
||
disabled={!!busy[n.id] || !n.token_configured}
|
||
onClick={() => void act(n.id, "update")}
|
||
>
|
||
更新代码
|
||
</button>
|
||
</div>
|
||
</article>
|
||
);
|
||
})}
|
||
</div>
|
||
{!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>
|
||
);
|
||
}
|