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:
+83
-24
@@ -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;
|
||||
}[];
|
||||
};
|
||||
|
||||
@@ -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