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:
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user