da5eb4c18c
Strategy nodes gain fleet token APIs; control/ app for local ops; manage.sh offers strategy vs control one-click deploy. Co-authored-by: Cursor <cursoragent@cursor.com>
211 lines
6.6 KiB
TypeScript
211 lines
6.6 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) ||
|
|
{};
|
|
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 || "-"),
|
|
};
|
|
}
|
|
|
|
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 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;
|
|
});
|
|
}
|
|
|
|
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"}`}>
|
|
<header className="node-card-head">
|
|
<label className="check">
|
|
<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 ? "已配对" : "未配对"}</dd>
|
|
</div>
|
|
</dl>
|
|
{n.error ? <div className="err soft">{n.error}</div> : null}
|
|
<div className="node-actions">
|
|
<button
|
|
type="button"
|
|
className="btn"
|
|
disabled={!!busy[n.id] || !n.token_configured}
|
|
onClick={() => void act(n.id, "start")}
|
|
>
|
|
启动
|
|
</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}
|
|
</div>
|
|
);
|
|
}
|