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:
dekun
2026-07-26 23:05:58 +08:00
parent 9d3b36826c
commit 2b348854fe
9 changed files with 946 additions and 25 deletions
+83 -24
View File
@@ -299,28 +299,87 @@ export type RuntimeSettings = {
sim: boolean;
};
export type StrategySettings = {
fee_rate: number;
exit_move_pct?: number;
exit_mode: "fixed_usdt" | "premium_multiple";
net_profit_target: number;
premium_exit_multiple: number;
rest_seconds: number;
live_order_interval_sec?: number;
skip_weekends: boolean;
initial_equity: number;
leverage: number;
min_option_hours: number;
min_option_leverage: number;
atm_open_offset_enabled: boolean;
max_atm_open_offset: number;
close_bid_mark_max_pct: number;
perp_qty_eth: number;
option_qty_eth: number;
show_manual_trade_buttons?: boolean;
exchange: "okx" | "binance";
perp_inst_id?: string;
option_inst_family?: string;
index_inst_id?: string;
ledger: { equity: number; available: number };
export async function downloadBackup(name: string): Promise<void> {
await ensureFreshToken();
const token = getToken();
const res = await fetch(
`${getApiBase()}/api/backup/download/${encodeURIComponent(name)}`,
{
headers: token ? { Authorization: `Bearer ${token}` } : {},
},
);
if (res.status === 401) {
clearSession();
throw new Error("unauthorized");
}
if (!res.ok) {
const text = await res.text();
let detail = res.statusText;
try {
const j = JSON.parse(text) as { detail?: string };
if (j.detail) detail = String(j.detail);
} catch {
if (text) detail = text;
}
throw new Error(detail || `HTTP ${res.status}`);
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
export async function uploadRestoreBackup(
file: File,
confirmPhrase: string,
): Promise<{ detail?: string; restart_required?: boolean }> {
await ensureFreshToken();
const token = getToken();
const res = await fetch(`${getApiBase()}/api/backup/restore`, {
method: "POST",
headers: {
...(token ? { Authorization: `Bearer ${token}` } : {}),
"X-Confirm-Phrase": confirmPhrase,
"Content-Type": "application/zip",
},
body: file,
});
if (res.status === 401) {
clearSession();
throw new Error("unauthorized");
}
const text = await res.text();
let data: unknown = null;
try {
data = text ? JSON.parse(text) : null;
} catch {
data = { detail: text };
}
if (!res.ok) {
const detail =
typeof data === "object" && data && "detail" in data
? String((data as { detail: unknown }).detail)
: res.statusText;
throw new Error(detail || `HTTP ${res.status}`);
}
return data as { detail?: string; restart_required?: boolean };
}
export type BackupStatus = {
auto_enabled: boolean;
interval_hours: number;
keep_count: number;
last_at_ms: number | null;
backup_dir: string;
items: {
name: string;
path: string;
size_bytes: number;
mtime_ms: number;
}[];
};
+273 -1
View File
@@ -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>
);
}