Files
eth_hedge_sim/control/frontend/src/pages/Monitor.tsx
T

437 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}