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
+1
View File
@@ -101,6 +101,7 @@ export default function App() {
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/fleet-login" element={<LoginPage />} />
<Route
path="/plan"
element={
+4 -1
View File
@@ -120,7 +120,10 @@ export async function apiFetch<T>(
path: string,
options: RequestInit = {},
): Promise<T> {
if (!path.startsWith("/api/auth/login")) {
if (
!path.startsWith("/api/auth/login") &&
!path.startsWith("/api/auth/fleet-exchange")
) {
await ensureFreshToken();
}
const base = getApiBase();
+58 -2
View File
@@ -1,13 +1,59 @@
import { FormEvent, useState } from "react";
import { useNavigate } from "react-router-dom";
import { FormEvent, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { login, setSession } from "../api/client";
type ExchangeRes = {
token: string;
username: string;
expires_in: number;
};
export default function LoginPage() {
const nav = useNavigate();
const [params] = useSearchParams();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [err, setErr] = useState("");
const [loading, setLoading] = useState(false);
const [ticketBusy, setTicketBusy] = useState(false);
useEffect(() => {
const ticket = (params.get("ticket") || "").trim();
if (!ticket) return;
let cancelled = false;
(async () => {
setTicketBusy(true);
setErr("");
try {
const res = await fetch(`${window.location.origin}/api/auth/fleet-exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ticket }),
});
const text = await res.text();
let data: ExchangeRes & { detail?: string } = { token: "", username: "", expires_in: 0 };
try {
data = text ? JSON.parse(text) : data;
} catch {
/* ignore */
}
if (!res.ok) {
throw new Error(data.detail || res.statusText || "兑换失败");
}
if (cancelled) return;
setSession(data.token, data.username, data.expires_in);
nav("/plan", { replace: true });
} catch (ex) {
if (!cancelled) {
setErr(ex instanceof Error ? ex.message : String(ex));
setTicketBusy(false);
}
}
})();
return () => {
cancelled = true;
};
}, [params, nav]);
async function onSubmit(e: FormEvent) {
e.preventDefault();
@@ -24,6 +70,16 @@ export default function LoginPage() {
}
}
if (ticketBusy && !err) {
return (
<div className="login-wrap">
<div className="login-box">
<p className="meta"></p>
</div>
</div>
);
}
return (
<div className="login-wrap">
<form className="login-box" onSubmit={onSubmit}>
+89 -1
View File
@@ -57,6 +57,9 @@ export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [fleetConfigured, setFleetConfigured] = useState(false);
const [fleetTokenInput, setFleetTokenInput] = useState("");
const [fleetHint, setFleetHint] = useState("");
const [err, setErr] = useState("");
const [ok, setOk] = useState("");
const [loading, setLoading] = useState(false);
@@ -526,7 +529,15 @@ export default function SettingsPage() {
<button
type="button"
className={tab === "account" ? "tab active" : "tab"}
onClick={() => setTab("account")}
onClick={() => {
setTab("account");
apiFetch<{ configured: boolean; hint?: string }>("/api/fleet/meta")
.then((r) => {
setFleetConfigured(!!r.configured);
setFleetHint(r.hint || "");
})
.catch(() => undefined);
}}
>
</button>
@@ -1648,7 +1659,83 @@ export default function SettingsPage() {
<div className="card settings-card">
{err ? <div className="err">{err}</div> : null}
{ok ? <div className="settings-ok">{ok}</div> : null}
<section className="settings-section">
<h3> API Token</h3>
<p className="meta">
{fleetHint ||
"在中控生成 Token 后粘贴保存。用于远程启停、更新与免密登录。"}
</p>
<p className="meta">
{fleetConfigured ? "已配置" : "未配置"}
</p>
<div className="settings-fields">
<div className="field">
<label htmlFor="fleetTok">Token</label>
<input
id="fleetTok"
type="password"
value={fleetTokenInput}
onChange={(e) => setFleetTokenInput(e.target.value)}
placeholder="粘贴中控生成的 Token"
autoComplete="off"
/>
</div>
</div>
<div className="settings-actions">
<button
className="btn"
type="button"
disabled={loading || !fleetTokenInput.trim()}
onClick={async () => {
setErr("");
setOk("");
setLoading(true);
try {
const r = await apiFetch<{ configured: boolean }>(
"/api/fleet/token",
{
method: "PUT",
body: JSON.stringify({ token: fleetTokenInput.trim() }),
},
);
setFleetConfigured(!!r.configured);
setFleetTokenInput("");
setOk("中控 Token 已保存");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}}
>
Token
</button>
<button
className="btn ghost"
type="button"
disabled={loading || !fleetConfigured}
onClick={async () => {
setErr("");
setOk("");
setLoading(true);
try {
await apiFetch("/api/fleet/token", { method: "DELETE" });
setFleetConfigured(false);
setOk("已清除中控 Token");
} catch (ex) {
setErr(ex instanceof Error ? ex.message : String(ex));
} finally {
setLoading(false);
}
}}
>
</button>
</div>
</section>
<form onSubmit={onSaveCreds} className="settings-account-form">
<section className="settings-section">
<h3></h3>
<div className="settings-fields">
<div className="field">
<label htmlFor="user"></label>
@@ -1695,6 +1782,7 @@ export default function SettingsPage() {
{loading ? "保存中…" : "保存账号"}
</button>
</div>
</section>
</form>
</div>
) : null}