Add DB+.env auto backup with download and upload restore in Settings.
Backups land under /root/eth_hedge_backups; restore accepts zip body for new-server migration and restarts the process. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -6,9 +6,12 @@ import {
|
||||
apiFetch,
|
||||
StrategySettings,
|
||||
RuntimeSettings,
|
||||
BackupStatus,
|
||||
downloadBackup,
|
||||
uploadRestoreBackup,
|
||||
} from "../api/client";
|
||||
|
||||
type Tab = "strategy" | "runtime" | "account";
|
||||
type Tab = "strategy" | "runtime" | "account" | "backup";
|
||||
type StratSub =
|
||||
| "position"
|
||||
| "select"
|
||||
@@ -87,6 +90,15 @@ export default function SettingsPage() {
|
||||
const [bnSecret, setBnSecret] = useState("");
|
||||
const [runtimeOk, setRuntimeOk] = useState("");
|
||||
|
||||
const [backup, setBackup] = useState<BackupStatus | null>(null);
|
||||
const [bakAuto, setBakAuto] = useState(true);
|
||||
const [bakInterval, setBakInterval] = useState(24);
|
||||
const [bakKeep, setBakKeep] = useState(14);
|
||||
const [bakOk, setBakOk] = useState("");
|
||||
const [bakBusy, setBakBusy] = useState("");
|
||||
const [restoreFile, setRestoreFile] = useState<File | null>(null);
|
||||
const [restorePhrase, setRestorePhrase] = useState("");
|
||||
|
||||
function loadRuntime() {
|
||||
apiFetch<RuntimeSettings>("/api/settings/runtime")
|
||||
.then((r) => {
|
||||
@@ -96,6 +108,17 @@ export default function SettingsPage() {
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
function loadBackup() {
|
||||
apiFetch<BackupStatus>("/api/backup/status")
|
||||
.then((s) => {
|
||||
setBackup(s);
|
||||
setBakAuto(s.auto_enabled !== false);
|
||||
setBakInterval(s.interval_hours ?? 24);
|
||||
setBakKeep(s.keep_count ?? 14);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch<StrategySettings>("/api/settings/strategy")
|
||||
.then((s) => {
|
||||
@@ -122,6 +145,89 @@ export default function SettingsPage() {
|
||||
loadRuntime();
|
||||
}, []);
|
||||
|
||||
async function onSaveBackupSettings(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
setBakOk("");
|
||||
try {
|
||||
const s = await apiFetch<BackupStatus>("/api/backup/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify({
|
||||
auto_enabled: bakAuto,
|
||||
interval_hours: bakInterval,
|
||||
keep_count: bakKeep,
|
||||
}),
|
||||
});
|
||||
setBackup(s);
|
||||
setBakOk("备份设置已保存");
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
}
|
||||
}
|
||||
|
||||
async function onBackupNow() {
|
||||
setErr("");
|
||||
setBakOk("");
|
||||
setBakBusy("备份中");
|
||||
try {
|
||||
const r = await apiFetch<{ name: string; backup_dir: string }>(
|
||||
"/api/backup/now",
|
||||
{ method: "POST", body: "{}" },
|
||||
);
|
||||
setBakOk(`已备份 ${r.name} → ${r.backup_dir}`);
|
||||
loadBackup();
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setBakBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function onDownload(name: string) {
|
||||
setErr("");
|
||||
setBakBusy("下载中");
|
||||
try {
|
||||
await downloadBackup(name);
|
||||
setBakOk(`已下载 ${name}`);
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setBakBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function onRestore() {
|
||||
setErr("");
|
||||
setBakOk("");
|
||||
if (!restoreFile) {
|
||||
setErr("请先选择备份 zip");
|
||||
return;
|
||||
}
|
||||
if (restorePhrase.trim() !== "RESTORE") {
|
||||
setErr("请输入确认串 RESTORE(大写)");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!window.confirm(
|
||||
"将用备份覆盖本机数据库与 .env,服务会自动重启。新服务器迁移请确认包来自可信来源。继续?",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setBakBusy("恢复中");
|
||||
try {
|
||||
const r = await uploadRestoreBackup(restoreFile, restorePhrase.trim());
|
||||
setBakOk(r.detail || "恢复成功,请等待重启后刷新");
|
||||
window.setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2500);
|
||||
} catch (ex) {
|
||||
setErr(ex instanceof Error ? ex.message : String(ex));
|
||||
} finally {
|
||||
setBakBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function onSaveCreds(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setErr("");
|
||||
@@ -276,6 +382,16 @@ export default function SettingsPage() {
|
||||
>
|
||||
登录账户
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={tab === "backup" ? "tab active" : "tab"}
|
||||
onClick={() => {
|
||||
setTab("backup");
|
||||
loadBackup();
|
||||
}}
|
||||
>
|
||||
备份恢复
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{tab === "strategy" ? (
|
||||
@@ -855,6 +971,162 @@ export default function SettingsPage() {
|
||||
</form>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "backup" ? (
|
||||
<div className="card settings-card">
|
||||
{bakOk ? <div className="settings-ok">{bakOk}</div> : null}
|
||||
{err && tab === "backup" ? <div className="err">{err}</div> : null}
|
||||
{bakBusy ? <div className="meta">{bakBusy}…</div> : null}
|
||||
|
||||
<form onSubmit={onSaveBackupSettings}>
|
||||
<section className="settings-section">
|
||||
<h3>自动备份</h3>
|
||||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||||
备份内容:SQLite 数据库 +{" "}
|
||||
<span className="mono">.env</span>(含登录与交易所密钥)。服务器目录:
|
||||
<span className="mono">
|
||||
{" "}
|
||||
{backup?.backup_dir || "/root/eth_hedge_backups"}
|
||||
</span>
|
||||
</p>
|
||||
<div className="settings-fields">
|
||||
<div className="field">
|
||||
<label htmlFor="bakAuto">自动备份</label>
|
||||
<select
|
||||
id="bakAuto"
|
||||
className="mono"
|
||||
value={bakAuto ? "1" : "0"}
|
||||
onChange={(e) => setBakAuto(e.target.value === "1")}
|
||||
>
|
||||
<option value="1">开启(默认)</option>
|
||||
<option value="0">关闭</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="bakInt">间隔(小时)</label>
|
||||
<input
|
||||
id="bakInt"
|
||||
className="mono"
|
||||
type="number"
|
||||
min={1}
|
||||
max={168}
|
||||
value={bakInterval}
|
||||
onChange={(e) => setBakInterval(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="bakKeep">保留份数</label>
|
||||
<input
|
||||
id="bakKeep"
|
||||
className="mono"
|
||||
type="number"
|
||||
min={1}
|
||||
max={90}
|
||||
value={bakKeep}
|
||||
onChange={(e) => setBakKeep(Number(e.target.value))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-actions" style={{ gap: 8 }}>
|
||||
<button className="btn" type="submit">
|
||||
保存备份设置
|
||||
</button>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={!!bakBusy}
|
||||
onClick={() => void onBackupNow()}
|
||||
>
|
||||
立即备份
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>服务器备份列表</h3>
|
||||
{backup?.items && backup.items.length > 0 ? (
|
||||
<div className="settings-fields">
|
||||
{backup.items.map((it) => (
|
||||
<div
|
||||
key={it.name}
|
||||
className="field"
|
||||
style={{
|
||||
display: "flex",
|
||||
flexWrap: "wrap",
|
||||
gap: 8,
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span className="mono" style={{ flex: "1 1 220px" }}>
|
||||
{it.name}
|
||||
<span style={{ color: "var(--muted)" }}>
|
||||
{" "}
|
||||
· {(it.size_bytes / 1024).toFixed(1)} KB ·{" "}
|
||||
{new Date(it.mtime_ms).toLocaleString()}
|
||||
</span>
|
||||
</span>
|
||||
<button
|
||||
className="btn ghost"
|
||||
type="button"
|
||||
disabled={!!bakBusy}
|
||||
onClick={() => void onDownload(it.name)}
|
||||
>
|
||||
下载
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ color: "var(--muted)" }}>暂无备份,可点「立即备份」。</p>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="settings-section">
|
||||
<h3>上传恢复(新服务器迁移)</h3>
|
||||
<p style={{ color: "var(--muted)", marginTop: 0 }}>
|
||||
在新机器部署后,上传旧机下载的 zip,将覆盖数据库与{" "}
|
||||
<span className="mono">.env</span>
|
||||
。须无持仓;确认串填写{" "}
|
||||
<span className="mono">RESTORE</span>
|
||||
。恢复后服务自动重启。
|
||||
</p>
|
||||
<div className="settings-fields">
|
||||
<div className="field">
|
||||
<label htmlFor="bakFile">备份 zip</label>
|
||||
<input
|
||||
id="bakFile"
|
||||
type="file"
|
||||
accept=".zip,application/zip"
|
||||
onChange={(e) =>
|
||||
setRestoreFile(e.target.files?.[0] ?? null)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="field">
|
||||
<label htmlFor="bakPhrase">确认串</label>
|
||||
<input
|
||||
id="bakPhrase"
|
||||
className="mono"
|
||||
value={restorePhrase}
|
||||
placeholder="RESTORE"
|
||||
onChange={(e) => setRestorePhrase(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="settings-actions">
|
||||
<button
|
||||
className="btn danger"
|
||||
type="button"
|
||||
disabled={!!bakBusy}
|
||||
onClick={() => void onRestore()}
|
||||
>
|
||||
上传并恢复
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user