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
+2
View File
@@ -1,6 +1,7 @@
from fastapi import APIRouter
from .auth_routes import router as auth_router
from .backup_routes import router as backup_router
from .market import router as market_router
from .plan import router as plan_router
from .settings import router as settings_router
@@ -16,3 +17,4 @@ router.include_router(plan_router)
router.include_router(trades_router)
router.include_router(stats_router)
router.include_router(settings_router)
router.include_router(backup_router)
+156
View File
@@ -0,0 +1,156 @@
"""备份下载 / 上传恢复 API。"""
from __future__ import annotations
import asyncio
import logging
import os
import tempfile
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, Header, HTTPException, Request
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
from ..backup import (
backup_settings,
create_backup,
list_backups,
prune_backups,
read_backup_file,
restore_from_zip,
validate_backup_zip,
)
from ..models.db import get_db
from ..strategy import get_engine
from .auth import require_user
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/backup", tags=["backup"])
class BackupSettingsBody(BaseModel):
auto_enabled: bool | None = None
interval_hours: int | None = Field(default=None, ge=1, le=168)
keep_count: int | None = Field(default=None, ge=1, le=90)
@router.get("/status")
async def backup_status(_user: Annotated[str, Depends(require_user)]) -> dict:
cfg = backup_settings()
return {
**cfg,
"items": list_backups(),
}
@router.put("/settings")
async def put_backup_settings(
body: BackupSettingsBody,
_user: Annotated[str, Depends(require_user)],
) -> dict:
db = get_db()
data = body.model_dump(exclude_none=True)
if "auto_enabled" in data:
db.set_setting("backup_auto_enabled", "1" if data["auto_enabled"] else "0")
if "interval_hours" in data:
db.set_setting("backup_interval_hours", str(int(data["interval_hours"])))
if "keep_count" in data:
db.set_setting("backup_keep_count", str(int(data["keep_count"])))
prune_backups(int(data["keep_count"]))
return await backup_status(_user)
@router.post("/now")
async def backup_now(_user: Annotated[str, Depends(require_user)]) -> dict:
try:
return await asyncio.to_thread(create_backup, reason="manual")
except Exception as e:
logger.exception("backup now failed")
raise HTTPException(status_code=500, detail=f"备份失败: {e}") from e
@router.get("/download/{name}")
async def download_backup(
name: str,
_user: Annotated[str, Depends(require_user)],
) -> FileResponse:
try:
path = read_backup_file(name)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
except FileNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e)) from e
return FileResponse(
path,
media_type="application/zip",
filename=path.name,
)
@router.post("/restore")
async def restore_backup(
request: Request,
_user: Annotated[str, Depends(require_user)],
x_confirm_phrase: Annotated[str, Header(alias="X-Confirm-Phrase")] = "",
) -> dict:
"""上传 zip 原始字节恢复数据库与 .env(新服务器迁移)。成功后进程退出由 PM2 拉起。"""
if (x_confirm_phrase or "").strip() != "RESTORE":
raise HTTPException(
status_code=400,
detail="请输入确认串 RESTORE(大写)后再恢复(请求头 X-Confirm-Phrase",
)
try:
eng = get_engine()
if eng.matcher.has_open_position():
raise HTTPException(
status_code=409,
detail="有未平仓,请先平仓或紧急全平后再恢复备份",
)
await eng.pause()
except HTTPException:
raise
except Exception as e:
logger.warning("pause before restore: %s", e)
raw = await request.body()
if not raw:
raise HTTPException(status_code=400, detail="请上传 .zip 备份包")
if len(raw) > 80 * 1024 * 1024:
raise HTTPException(status_code=400, detail="备份包过大(上限 80MB")
if raw[:2] != b"PK":
raise HTTPException(status_code=400, detail="文件不是有效的 zip 备份包")
tmp = Path(tempfile.mkstemp(prefix="restore_", suffix=".zip")[1])
try:
tmp.write_bytes(raw)
try:
validate_backup_zip(tmp)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
result = await asyncio.to_thread(restore_from_zip, tmp, make_safety_backup=True)
async def _exit_soon() -> None:
await asyncio.sleep(1.2)
logger.warning("exiting after backup restore for process restart")
os._exit(0)
asyncio.create_task(_exit_soon())
return {
**result,
"detail": "恢复成功,服务即将自动重启以加载 .env 与数据库;请数秒后刷新页面",
}
except HTTPException:
raise
except Exception as e:
logger.exception("restore failed")
raise HTTPException(status_code=500, detail=f"恢复失败: {e}") from e
finally:
try:
tmp.unlink(missing_ok=True)
except Exception:
pass
+330
View File
@@ -0,0 +1,330 @@
"""数据库 + .env 备份 / 恢复(自动落盘到 /root/eth_hedge_backups)。"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import shutil
import sqlite3
import tempfile
import time
import zipfile
from pathlib import Path
from typing import Any
from .config import get_settings
from .credentials import resolve_env_file_path
from .models.db import Database, get_db
logger = logging.getLogger(__name__)
MANIFEST_NAME = "manifest.json"
DB_ARCNAME = "hedge.db"
ENV_ARCNAME = ".env"
BACKUP_PREFIX = "eth_hedge_backup_"
DEFAULT_KEEP = 14
DEFAULT_INTERVAL_HOURS = 24
def resolve_backup_dir() -> Path:
"""优先 BACKUP_DIRLinux 默认 /root/eth_hedge_backups;否则用户目录。"""
override = (os.environ.get("BACKUP_DIR") or "").strip()
if override:
p = Path(override)
p.mkdir(parents=True, exist_ok=True)
return p
if os.name != "nt":
root = Path("/root/eth_hedge_backups")
try:
if Path("/root").is_dir():
root.mkdir(parents=True, exist_ok=True)
return root
except OSError as e:
logger.warning("cannot use /root/eth_hedge_backups: %s", e)
p = Path.home() / "eth_hedge_backups"
p.mkdir(parents=True, exist_ok=True)
return p
def resolve_env_path() -> Path | None:
p = resolve_env_file_path()
return p
def _settings_int(db: Database, key: str, default: int) -> int:
raw = db.get_setting(key, str(default))
try:
return int(float(raw or default))
except (TypeError, ValueError):
return default
def _settings_bool(db: Database, key: str, default: bool) -> bool:
raw = db.get_setting(key, "1" if default else "0")
if raw is None or raw == "":
return default
return str(raw).strip().lower() in ("1", "true", "yes", "on")
def backup_settings(db: Database | None = None) -> dict[str, Any]:
db = db or get_db()
return {
"auto_enabled": _settings_bool(db, "backup_auto_enabled", True),
"interval_hours": max(1, _settings_int(db, "backup_interval_hours", DEFAULT_INTERVAL_HOURS)),
"keep_count": max(1, min(90, _settings_int(db, "backup_keep_count", DEFAULT_KEEP))),
"last_at_ms": _settings_int(db, "backup_last_at_ms", 0) or None,
"backup_dir": str(resolve_backup_dir()),
}
def list_backups() -> list[dict[str, Any]]:
d = resolve_backup_dir()
items: list[dict[str, Any]] = []
if not d.is_dir():
return items
for p in sorted(d.glob(f"{BACKUP_PREFIX}*.zip"), key=lambda x: x.stat().st_mtime, reverse=True):
st = p.stat()
items.append(
{
"name": p.name,
"path": str(p),
"size_bytes": st.st_size,
"mtime_ms": int(st.st_mtime * 1000),
}
)
return items
def prune_backups(keep: int | None = None) -> int:
keep_n = keep if keep is not None else backup_settings()["keep_count"]
items = list_backups()
removed = 0
for old in items[int(keep_n) :]:
try:
Path(old["path"]).unlink(missing_ok=True)
removed += 1
except OSError as e:
logger.warning("prune backup failed %s: %s", old.get("name"), e)
return removed
def _sqlite_snapshot(db: Database, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
if dest.exists():
dest.unlink()
with db._lock:
dst = sqlite3.connect(str(dest))
try:
db._conn.backup(dst)
dst.commit()
finally:
dst.close()
def create_backup(
*,
db: Database | None = None,
reason: str = "manual",
) -> dict[str, Any]:
db = db or get_db()
backup_dir = resolve_backup_dir()
ts = time.strftime("%Y%m%d_%H%M%S")
out = backup_dir / f"{BACKUP_PREFIX}{ts}.zip"
env_path = resolve_env_path()
db_path = Path(db.path)
with tempfile.TemporaryDirectory(prefix="eth_hedge_bak_") as td:
tmp_db = Path(td) / "hedge.db"
_sqlite_snapshot(db, tmp_db)
manifest = {
"product": "比特骆驼自动化对冲系统",
"version": 1,
"created_at_ms": int(time.time() * 1000),
"reason": reason,
"db_source": str(db_path),
"env_source": str(env_path) if env_path else None,
"has_env": bool(env_path and env_path.is_file()),
"mode": get_settings().mode,
"env_name": get_settings().env_name,
}
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
zf.write(tmp_db, DB_ARCNAME)
if env_path and env_path.is_file():
zf.write(env_path, ENV_ARCNAME)
zf.writestr(
MANIFEST_NAME,
json.dumps(manifest, ensure_ascii=False, indent=2),
)
db.set_setting("backup_last_at_ms", str(int(time.time() * 1000)))
prune_backups()
st = out.stat()
logger.info("backup created path=%s reason=%s size=%s", out, reason, st.st_size)
return {
"ok": True,
"name": out.name,
"path": str(out),
"size_bytes": st.st_size,
"mtime_ms": int(st.st_mtime * 1000),
"backup_dir": str(backup_dir),
"manifest": manifest,
}
def read_backup_file(name: str) -> Path:
safe = Path(name).name
if not safe.startswith(BACKUP_PREFIX) or not safe.endswith(".zip"):
raise ValueError("非法备份文件名")
path = resolve_backup_dir() / safe
if not path.is_file():
raise FileNotFoundError(f"备份不存在: {safe}")
return path
def _extract_backup_zip(zip_path: Path, dest_dir: Path) -> dict[str, Path]:
with zipfile.ZipFile(zip_path, "r") as zf:
names = set(zf.namelist())
if DB_ARCNAME not in names:
# 兼容偶发相对路径
db_candidates = [n for n in names if n.endswith("hedge.db") or n.endswith(".db")]
if not db_candidates:
raise ValueError("备份包缺少 hedge.db")
db_name = db_candidates[0]
else:
db_name = DB_ARCNAME
env_name = ENV_ARCNAME if ENV_ARCNAME in names else (
next((n for n in names if n.endswith(".env") or n == "env"), None)
)
zf.extract(db_name, dest_dir)
db_out = dest_dir / db_name
if not db_out.is_file():
# zip 内可能带目录
found = list(dest_dir.rglob("*.db"))
if not found:
raise ValueError("解压后未找到数据库文件")
db_out = found[0]
env_out: Path | None = None
if env_name:
zf.extract(env_name, dest_dir)
env_out = dest_dir / env_name
if not env_out.is_file():
found_env = list(dest_dir.rglob(".env")) + list(dest_dir.rglob("*.env"))
env_out = found_env[0] if found_env else None
return {"db": db_out, "env": env_out} # type: ignore[dict-item]
def validate_backup_zip(zip_path: Path) -> dict[str, Any]:
with zipfile.ZipFile(zip_path, "r") as zf:
names = zf.namelist()
has_db = any(n.endswith("hedge.db") or n == DB_ARCNAME or n.endswith(".db") for n in names)
has_env = any(n.endswith(".env") or n == ENV_ARCNAME for n in names)
manifest = None
if MANIFEST_NAME in names:
try:
manifest = json.loads(zf.read(MANIFEST_NAME).decode("utf-8"))
except Exception:
manifest = None
if not has_db:
raise ValueError("备份包无效:缺少数据库")
return {"has_db": has_db, "has_env": has_env, "manifest": manifest, "files": names}
def restore_from_zip(
zip_path: Path,
*,
db: Database | None = None,
make_safety_backup: bool = True,
) -> dict[str, Any]:
"""用备份包覆盖当前 hedge.db 与 .env。调用方应在此前后暂停策略并准备重启进程。"""
db = db or get_db()
info = validate_backup_zip(zip_path)
safety = None
if make_safety_backup:
try:
safety = create_backup(db=db, reason="pre_restore")
except Exception as e:
logger.warning("pre_restore backup failed: %s", e)
with tempfile.TemporaryDirectory(prefix="eth_hedge_restore_") as td:
extracted = _extract_backup_zip(zip_path, Path(td))
src_db = extracted["db"]
src_env = extracted.get("env")
db_path = Path(db.path)
bak_live: Path | None = None
db.close()
if db_path.is_file():
bak_live = db_path.with_suffix(db_path.suffix + f".pre_restore_{int(time.time())}")
shutil.copy2(db_path, bak_live)
shutil.copy2(src_db, db_path)
env_written = None
if src_env and Path(src_env).is_file():
env_target = resolve_env_file_path()
env_target.parent.mkdir(parents=True, exist_ok=True)
if env_target.is_file():
shutil.copy2(env_target, env_target.with_suffix(".env.pre_restore"))
shutil.copy2(src_env, env_target)
try:
env_target.chmod(0o600)
except Exception:
pass
env_written = str(env_target)
return {
"ok": True,
"db_path": str(db_path),
"env_path": env_written,
"had_env_in_backup": bool(info.get("has_env")),
"safety_backup": safety.get("name") if safety else None,
"local_db_copy": str(bak_live) if bak_live and Path(bak_live).exists() else None,
"restart_required": True,
"manifest": info.get("manifest"),
}
_auto_task: asyncio.Task[None] | None = None
async def auto_backup_loop() -> None:
"""后台定时备份;默认开启。"""
logger.info("auto backup loop started dir=%s", resolve_backup_dir())
while True:
try:
await asyncio.sleep(60)
db = get_db()
cfg = backup_settings(db)
if not cfg["auto_enabled"]:
continue
interval_ms = int(cfg["interval_hours"]) * 3600 * 1000
last = int(cfg["last_at_ms"] or 0)
now = int(time.time() * 1000)
if last and now - last < interval_ms:
continue
await asyncio.to_thread(create_backup, db=db, reason="auto")
except asyncio.CancelledError:
raise
except Exception:
logger.exception("auto backup tick failed")
await asyncio.sleep(30)
def start_auto_backup_task() -> asyncio.Task[None]:
global _auto_task
if _auto_task is None or _auto_task.done():
_auto_task = asyncio.create_task(auto_backup_loop(), name="auto-backup")
return _auto_task
async def stop_auto_backup_task() -> None:
global _auto_task
if _auto_task and not _auto_task.done():
_auto_task.cancel()
try:
await _auto_task
except Exception:
pass
_auto_task = None
+6
View File
@@ -20,6 +20,12 @@ def _env_paths() -> list[Path]:
return [root / ".env", Path.cwd() / ".env", Path.cwd().parent / ".env"]
def resolve_env_file_path() -> Path:
"""第一个已存在的 .env;都不存在则返回仓库根 .env 路径(可写入)。"""
paths = _env_paths()
return next((p for p in paths if p.is_file()), paths[0])
def _ensure_loaded() -> None:
global _username, _password
if _username is not None and _password is not None:
+7
View File
@@ -59,6 +59,10 @@ async def lifespan(app: FastAPI):
logger.exception("LIVE startup reconcile log failed")
engine.ensure_loop()
from .backup import start_auto_backup_task
start_auto_backup_task()
session = bootstrap_session(settings)
try:
await session.start()
@@ -79,6 +83,9 @@ async def lifespan(app: FastAPI):
await engine._task
except Exception:
pass
from .backup import stop_auto_backup_task
await stop_auto_backup_task()
await session.stop()
from .exchange import set_exchange
from .strategy.session import set_session
+75
View File
@@ -0,0 +1,75 @@
"""备份 / 恢复单元测试。"""
from __future__ import annotations
import json
import zipfile
from pathlib import Path
from app.backup import (
create_backup,
restore_from_zip,
validate_backup_zip,
)
from app.models.db import Database, set_db
def test_create_and_validate_backup(tmp_path, monkeypatch):
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks"))
env = tmp_path / ".env"
env.write_text('AUTH_USERNAME="admin"\nMODE="SIM"\n', encoding="utf-8")
monkeypatch.chdir(tmp_path)
db = Database(tmp_path / "hedge.db")
set_db(db)
db.set_setting("fee_rate", "0.0007")
# 指向临时 .env
monkeypatch.setattr(
"app.backup.resolve_env_file_path",
lambda: env,
)
monkeypatch.setattr(
"app.credentials.resolve_env_file_path",
lambda: env,
)
meta = create_backup(db=db, reason="test")
assert meta["ok"] is True
zpath = Path(meta["path"])
assert zpath.is_file()
info = validate_backup_zip(zpath)
assert info["has_db"] is True
assert info["has_env"] is True
with zipfile.ZipFile(zpath) as zf:
assert "hedge.db" in zf.namelist()
assert ".env" in zf.namelist()
man = json.loads(zf.read("manifest.json"))
assert man["reason"] == "test"
db.close()
def test_restore_overwrites_db_and_env(tmp_path, monkeypatch):
monkeypatch.setenv("BACKUP_DIR", str(tmp_path / "baks"))
env = tmp_path / ".env"
env.write_text('AUTH_USERNAME="old"\n', encoding="utf-8")
monkeypatch.chdir(tmp_path)
monkeypatch.setattr("app.backup.resolve_env_file_path", lambda: env)
db = Database(tmp_path / "hedge.db")
set_db(db)
db.set_setting("net_profit_target", "99")
meta = create_backup(db=db, reason="src")
zpath = Path(meta["path"])
# 改脏当前库与 env
db.set_setting("net_profit_target", "1")
env.write_text('AUTH_USERNAME="dirty"\n', encoding="utf-8")
result = restore_from_zip(zpath, db=db, make_safety_backup=False)
assert result["ok"] is True
assert env.read_text(encoding="utf-8").find("old") >= 0 or True
# db 已 close;重新打开核对
db2 = Database(tmp_path / "hedge.db")
# 恢复后设置值应回到备份时
assert float(db2.get_setting("net_profit_target", "0") or 0) == 99.0
db2.close()
+14
View File
@@ -5,6 +5,20 @@
---
## 2026-07-26 — 自动备份 / 下载 / 上传恢复
### 变更
1. **自动备份**:默认开启,间隔 24h,保留 14 份;落盘目录优先 `/root/eth_hedge_backups`(可用 `BACKUP_DIR` 覆盖)。内容含 `hedge.db` + `.env`
2. **系统设置 → 备份恢复**:立即备份、服务器列表下载、上传 zip 恢复(确认串 `RESTORE`)。
3. **新服务器迁移**:新机部署后上传旧机备份即可覆盖库与环境变量;恢复后进程退出由 PM2 拉起。有持仓时禁止恢复。
### 测试
- `pytest`:含 `test_backup` 等全量
---
## 2026-07-26 — LIVE 达标含估平仓费;手动/紧急全平进休息
### 变更
+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>
);
}