Add Fleet control plane and split manage.sh deploy menu.

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>
This commit is contained in:
dekun
2026-07-30 10:46:40 +08:00
parent 200702d066
commit da5eb4c18c
44 changed files with 4623 additions and 15 deletions
+51
View File
@@ -0,0 +1,51 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "../api";
export default function LoginPage() {
const nav = useNavigate();
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
async function onSubmit(e: FormEvent) {
e.preventDefault();
setErr("");
setLoading(true);
try {
await login(username.trim(), password);
nav("/monitor", { replace: true });
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
<h1></h1>
<p className="meta"> · admin / admin123</p>
{err ? <div className="err">{err}</div> : null}
<label>
<input value={username} onChange={(e) => setUsername(e.target.value)} required />
</label>
<label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
</label>
<button className="btn" type="submit" disabled={loading}>
{loading ? "登录中…" : "登录"}
</button>
</form>
</div>
);
}
+210
View File
@@ -0,0 +1,210 @@
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>
);
}
+241
View File
@@ -0,0 +1,241 @@
import { FormEvent, useEffect, useState } from "react";
import { apiFetch, getUsername, setSession, type NodeCard } from "../api";
export default function SettingsPage() {
const [nodes, setNodes] = useState<NodeCard[]>([]);
const [name, setName] = useState("");
const [baseUrl, setBaseUrl] = useState("https://");
const [err, setErr] = useState("");
const [ok, setOk] = useState("");
const [lastToken, setLastToken] = useState<{ id: number; token: string } | null>(
null,
);
const [newUsername, setNewUsername] = useState(getUsername() || "admin");
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [credBusy, setCredBusy] = useState(false);
async function load() {
const r = await apiFetch<{ nodes: NodeCard[] }>("/api/nodes/");
setNodes(r.nodes || []);
}
useEffect(() => {
load().catch((ex) => setErr(ex instanceof Error ? ex.message : String(ex)));
}, []);
async function onAdd(e: FormEvent) {
e.preventDefault();
setErr("");
setOk("");
try {
await apiFetch("/api/nodes/", {
method: "POST",
body: JSON.stringify({ name: name.trim(), base_url: baseUrl.trim() }),
});
setName("");
setOk("已添加策略机");
await load();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
async function onSaveCreds(e: FormEvent) {
e.preventDefault();
setErr("");
setOk("");
if (newPassword !== confirmPassword) {
setErr("两次新密码不一致");
return;
}
setCredBusy(true);
try {
const r = await apiFetch<{ token: string; username: string }>(
"/api/auth/change-credentials",
{
method: "POST",
body: JSON.stringify({
current_password: currentPassword,
new_username: newUsername.trim(),
new_password: newPassword,
}),
},
);
setSession(r.token, r.username);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
setOk("中控账号已更新");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setCredBusy(false);
}
}
async function genToken(id: number) {
setErr("");
setOk("");
try {
const r = await apiFetch<{ token: string; msg: string }>(
`/api/nodes/${id}/generate-token`,
{ method: "POST" },
);
setLastToken({ id, token: r.token });
setOk(r.msg || "已生成 Token");
await load();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
async function remove(id: number) {
if (!window.confirm("确认删除该策略机?")) return;
setErr("");
try {
await apiFetch(`/api/nodes/${id}`, { method: "DELETE" });
await load();
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
}
}
return (
<div>
<h2></h2>
<p className="meta">
访 Token Token
</p>
{err ? <div className="err">{err}</div> : null}
{ok ? <div className="ok">{ok}</div> : null}
<form className="add-form" onSubmit={onSaveCreds}>
<h3></h3>
<p className="meta"> admin / admin123</p>
<label>
<input
value={newUsername}
onChange={(e) => setNewUsername(e.target.value)}
required
/>
</label>
<label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
/>
</label>
<label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
minLength={6}
required
/>
</label>
<label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
minLength={6}
required
/>
</label>
<button className="btn" type="submit" disabled={credBusy}>
{credBusy ? "保存中…" : "保存账号"}
</button>
</form>
{lastToken ? (
<div className="token-box">
<div>
#{lastToken.id} Token
</div>
<code className="mono">{lastToken.token}</code>
<button
type="button"
className="btn ghost"
onClick={() => {
void navigator.clipboard.writeText(lastToken.token);
setOk("已复制到剪贴板");
}}
>
</button>
</div>
) : null}
<form className="add-form" onSubmit={onAdd}>
<h3></h3>
<label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="例如 云机-A"
required
/>
</label>
<label>
Base URL
<input
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="https://dc.hyf2.cc"
required
/>
</label>
<button className="btn" type="submit">
</button>
</form>
<div className="table-wrap">
<table>
<thead>
<tr>
<th>ID</th>
<th></th>
<th>URL</th>
<th>Token</th>
<th></th>
</tr>
</thead>
<tbody>
{nodes.map((n) => (
<tr key={n.id}>
<td>{n.id}</td>
<td>{n.name}</td>
<td className="mono">{n.base_url}</td>
<td>{n.token_configured ? "已生成" : "无"}</td>
<td className="row-actions">
<button type="button" className="btn" onClick={() => void genToken(n.id)}>
Token
</button>
<button
type="button"
className="btn ghost"
onClick={() => void remove(n.id)}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}