中控备份支持上传 tar.gz 后再恢复,便于换机迁移。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-05 18:45:37 +08:00
parent e18dd41c2d
commit 02ca76ea7c
4 changed files with 541 additions and 442 deletions
+11
View File
@@ -36,6 +36,7 @@ from lib.backup_lib import (
create_backup as create_hub_backup, create_backup as create_hub_backup,
list_backups as list_hub_backups, list_backups as list_hub_backups,
restore_backup as restore_hub_backup, restore_backup as restore_hub_backup,
save_uploaded_backup as save_hub_uploaded_backup,
start_backup_scheduler as start_hub_backup_scheduler, start_backup_scheduler as start_hub_backup_scheduler,
) )
from lib.bridge_client_lib import ( from lib.bridge_client_lib import (
@@ -397,6 +398,16 @@ def api_hub_backup_restore():
return jsonify(result), (200 if result.get("ok") else 400) return jsonify(result), (200 if result.get("ok") else 400)
@app.route("/api/settings/backup/hub/upload", methods=["POST"])
@login_required
def api_hub_backup_upload():
fs = request.files.get("file")
if fs is None or not getattr(fs, "filename", None):
return jsonify({"ok": False, "msg": "请选择备份文件"}), 400
result = save_hub_uploaded_backup(fs)
return jsonify(result), (200 if result.get("ok") else 400)
@app.route("/api/settings/backup/instances", methods=["GET"]) @app.route("/api/settings/backup/instances", methods=["GET"])
@login_required @login_required
def api_backup_instances(): def api_backup_instances():
+284 -228
View File
@@ -1,228 +1,284 @@
"""中控备份/恢复: .env + hub.db;默认落盘 /root/crypto_okx_hub_backups.""" """中控备份/恢复: .env + hub.db;默认落盘 /root/crypto_okx_hub_backups."""
from __future__ import annotations from __future__ import annotations
import json import json
import os import os
import re import re
import shutil import shutil
import tarfile import tarfile
import tempfile import tempfile
import threading import threading
import time import time
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from typing import Any, Callable from typing import Any, Callable
_BJ = timezone(timedelta(hours=8)) _BJ = timezone(timedelta(hours=8))
_SAFE_NAME_RE = re.compile(r"^crypto_okx_hub_backup_\d{8}_\d{6}\.tar\.gz$") _SAFE_NAME_RE = re.compile(r"^crypto_okx_hub_backup_\d{8}_\d{6}\.tar\.gz$")
_LOCK = threading.Lock() _LOCK = threading.Lock()
_LAST_AUTO_DAY = "" _LAST_AUTO_DAY = ""
def beijing_now() -> datetime: def beijing_now() -> datetime:
return datetime.now(_BJ) return datetime.now(_BJ)
def backup_keep_days() -> int: def backup_keep_days() -> int:
try: try:
return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10)) return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10))
except (TypeError, ValueError): except (TypeError, ValueError):
return 10 return 10
def backup_dir() -> str: def backup_dir() -> str:
root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root" root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root"
path = os.path.join(root, "crypto_okx_hub_backups") path = os.path.join(root, "crypto_okx_hub_backups")
os.makedirs(path, exist_ok=True) os.makedirs(path, exist_ok=True)
return path return path
def _safe_backup_name(name: str) -> str | None: def _safe_backup_name(name: str) -> str | None:
n = os.path.basename((name or "").strip()) n = os.path.basename((name or "").strip())
if not _SAFE_NAME_RE.match(n): if not _SAFE_NAME_RE.match(n):
return None return None
return n return n
def backup_path(name: str) -> str | None: def backup_path(name: str) -> str | None:
safe = _safe_backup_name(name) safe = _safe_backup_name(name)
if not safe: if not safe:
return None return None
path = os.path.abspath(os.path.join(backup_dir(), safe)) path = os.path.abspath(os.path.join(backup_dir(), safe))
root = os.path.abspath(backup_dir()) root = os.path.abspath(backup_dir())
if not path.startswith(root + os.sep) and path != root: if not path.startswith(root + os.sep) and path != root:
return None return None
return path return path
def list_backups() -> list[dict[str, Any]]: def list_backups() -> list[dict[str, Any]]:
root = backup_dir() root = backup_dir()
items: list[dict[str, Any]] = [] items: list[dict[str, Any]] = []
try: try:
names = os.listdir(root) names = os.listdir(root)
except OSError: except OSError:
return [] return []
for name in names: for name in names:
if not _SAFE_NAME_RE.match(name): if not _SAFE_NAME_RE.match(name):
continue continue
path = os.path.join(root, name) path = os.path.join(root, name)
try: try:
st = os.stat(path) st = os.stat(path)
except OSError: except OSError:
continue continue
items.append( items.append(
{ {
"name": name, "name": name,
"size": int(st.st_size), "size": int(st.st_size),
"mtime": int(st.st_mtime), "mtime": int(st.st_mtime),
"mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime( "mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime(
"%Y-%m-%d %H:%M:%S" "%Y-%m-%d %H:%M:%S"
), ),
} }
) )
items.sort(key=lambda x: x["mtime"], reverse=True) items.sort(key=lambda x: x["mtime"], reverse=True)
return items return items
def prune_backups(*, keep_days: int | None = None) -> int: def prune_backups(*, keep_days: int | None = None) -> int:
days = backup_keep_days() if keep_days is None else max(1, int(keep_days)) days = backup_keep_days() if keep_days is None else max(1, int(keep_days))
cutoff = time.time() - days * 86400 cutoff = time.time() - days * 86400
removed = 0 removed = 0
for item in list_backups(): for item in list_backups():
if float(item["mtime"]) >= cutoff: if float(item["mtime"]) >= cutoff:
continue continue
path = backup_path(item["name"]) path = backup_path(item["name"])
if not path: if not path:
continue continue
try: try:
os.remove(path) os.remove(path)
removed += 1 removed += 1
except OSError: except OSError:
pass pass
return removed return removed
def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]: def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]:
with _LOCK: with _LOCK:
now = beijing_now() now = beijing_now()
name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz" name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
out_path = os.path.join(backup_dir(), name) out_path = os.path.join(backup_dir(), name)
manifest = { manifest = {
"kind": "crypto_okx_hub", "kind": "crypto_okx_hub",
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"), "created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"label": (label or "manual").strip() or "manual", "label": (label or "manual").strip() or "manual",
"includes": [".env", "database"], "includes": [".env", "database"],
} }
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_") tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_")
os.close(tmp_fd) os.close(tmp_fd)
try: try:
with tarfile.open(tmp_path, "w:gz") as tar: with tarfile.open(tmp_path, "w:gz") as tar:
manifest_path = tmp_path + ".manifest.json" manifest_path = tmp_path + ".manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f: with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2) json.dump(manifest, f, ensure_ascii=False, indent=2)
try: try:
tar.add(manifest_path, arcname="manifest.json") tar.add(manifest_path, arcname="manifest.json")
finally: finally:
try: try:
os.remove(manifest_path) os.remove(manifest_path)
except OSError: except OSError:
pass pass
if env_path and os.path.isfile(env_path): if env_path and os.path.isfile(env_path):
tar.add(env_path, arcname=".env") tar.add(env_path, arcname=".env")
if db_path and os.path.isfile(db_path): if db_path and os.path.isfile(db_path):
tar.add(db_path, arcname="database/" + os.path.basename(db_path)) tar.add(db_path, arcname="database/" + os.path.basename(db_path))
shutil.move(tmp_path, out_path) shutil.move(tmp_path, out_path)
except Exception: except Exception:
try: try:
os.remove(tmp_path) os.remove(tmp_path)
except OSError: except OSError:
pass pass
raise raise
prune_backups() prune_backups()
st = os.stat(out_path) st = os.stat(out_path)
return { return {
"ok": True, "ok": True,
"name": name, "name": name,
"path": out_path, "path": out_path,
"size": int(st.st_size), "size": int(st.st_size),
"mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"), "mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"keep_days": backup_keep_days(), "keep_days": backup_keep_days(),
} }
def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]: def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]:
path = backup_path(name) path = backup_path(name)
if not path or not os.path.isfile(path): if not path or not os.path.isfile(path):
return {"ok": False, "msg": "备份不存在"} return {"ok": False, "msg": "备份不存在"}
with _LOCK: with _LOCK:
with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp: with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp:
with tarfile.open(path, "r:gz") as tar: with tarfile.open(path, "r:gz") as tar:
tar.extractall(tmp) tar.extractall(tmp)
env_src = os.path.join(tmp, ".env") env_src = os.path.join(tmp, ".env")
if os.path.isfile(env_src) and env_path: if os.path.isfile(env_src) and env_path:
shutil.copy2(env_src, env_path) shutil.copy2(env_src, env_path)
db_src_dir = os.path.join(tmp, "database") db_src_dir = os.path.join(tmp, "database")
if os.path.isdir(db_src_dir) and db_path: if os.path.isdir(db_src_dir) and db_path:
for fn in os.listdir(db_src_dir): for fn in os.listdir(db_src_dir):
src = os.path.join(db_src_dir, fn) src = os.path.join(db_src_dir, fn)
if os.path.isfile(src): if os.path.isfile(src):
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True) os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
shutil.copy2(src, db_path) shutil.copy2(src, db_path)
break break
return { return {
"ok": True, "ok": True,
"msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效", "msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效",
"name": os.path.basename(path), "name": os.path.basename(path),
} }
def maybe_run_auto_backup( _MAX_UPLOAD_BYTES = 32 * 1024 * 1024
*,
root_dir: str,
env_path: str, def save_uploaded_backup(file_storage: Any) -> dict[str, Any]:
db_path: str, """把本机下载的中控备份包写入备份目录,不自动恢复."""
hour: int = 3, filename = os.path.basename(str(getattr(file_storage, "filename", "") or "").strip())
) -> dict[str, Any] | None: safe = _safe_backup_name(filename)
global _LAST_AUTO_DAY if not safe:
now = beijing_now() return {
day = now.strftime("%Y-%m-%d") "ok": False,
if now.hour != int(hour): "msg": "文件名须为 crypto_okx_hub_backup_YYYYMMDD_HHMMSS.tar.gz",
return None }
if _LAST_AUTO_DAY == day: dest = os.path.join(backup_dir(), safe)
return None tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_up_")
prefix = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d')}_" os.close(tmp_fd)
for item in list_backups(): try:
if str(item["name"]).startswith(prefix): file_storage.save(tmp_path)
_LAST_AUTO_DAY = day total = os.path.getsize(tmp_path)
return None if total <= 0:
result = create_backup( os.remove(tmp_path)
root_dir=root_dir, return {"ok": False, "msg": "上传文件为空"}
env_path=env_path, if total > _MAX_UPLOAD_BYTES:
db_path=db_path, os.remove(tmp_path)
label="auto", return {"ok": False, "msg": "备份文件超过 32MB"}
) with tarfile.open(tmp_path, "r:gz") as tar:
_LAST_AUTO_DAY = day names = tar.getnames()
return result has_env = any(n.replace("\\", "/").rstrip("/") in (".env", "./.env") for n in names)
has_db = any(
n.replace("\\", "/").startswith("database/") and n.replace("\\", "/") != "database/"
def start_backup_scheduler( for n in names
*, )
root_dir: str, if not has_env and not has_db:
env_path: str, os.remove(tmp_path)
db_path_fn: Callable[[], str], return {"ok": False, "msg": "不是中控备份包(缺少 .env / database)"}
hour: int = 3, shutil.move(tmp_path, dest)
) -> None: except tarfile.TarError:
def loop() -> None: try:
while True: os.remove(tmp_path)
try: except OSError:
maybe_run_auto_backup( pass
root_dir=root_dir, return {"ok": False, "msg": "无法读取 tar.gz"}
env_path=env_path, except Exception as exc:
db_path=db_path_fn(), try:
hour=hour, os.remove(tmp_path)
) except OSError:
except Exception: pass
pass return {"ok": False, "msg": str(exc) or "上传失败"}
time.sleep(30) st = os.stat(dest)
return {
t = threading.Thread(target=loop, name="hub-backup-scheduler", daemon=True) "ok": True,
t.start() "name": safe,
"size": int(st.st_size),
"msg": "已上传,请在列表中点恢复",
}
def maybe_run_auto_backup(
*,
root_dir: str,
env_path: str,
db_path: str,
hour: int = 3,
) -> dict[str, Any] | None:
global _LAST_AUTO_DAY
now = beijing_now()
day = now.strftime("%Y-%m-%d")
if now.hour != int(hour):
return None
if _LAST_AUTO_DAY == day:
return None
prefix = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d')}_"
for item in list_backups():
if str(item["name"]).startswith(prefix):
_LAST_AUTO_DAY = day
return None
result = create_backup(
root_dir=root_dir,
env_path=env_path,
db_path=db_path,
label="auto",
)
_LAST_AUTO_DAY = day
return result
def start_backup_scheduler(
*,
root_dir: str,
env_path: str,
db_path_fn: Callable[[], str],
hour: int = 3,
) -> None:
def loop() -> None:
while True:
try:
maybe_run_auto_backup(
root_dir=root_dir,
env_path=env_path,
db_path=db_path_fn(),
hour=hour,
)
except Exception:
pass
time.sleep(30)
t = threading.Thread(target=loop, name="hub-backup-scheduler", daemon=True)
t.start()
+240 -212
View File
@@ -1,212 +1,240 @@
(function (global) { (function (global) {
function escapeHtml(s) { function escapeHtml(s) {
return String(s) return String(s)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function fmtSize(n) { function fmtSize(n) {
const v = Number(n) || 0; const v = Number(n) || 0;
if (v < 1024) return v + " B"; if (v < 1024) return v + " B";
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB"; if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
return (v / (1024 * 1024)).toFixed(2) + " MB"; return (v / (1024 * 1024)).toFixed(2) + " MB";
} }
function setText(id, msg, isErr) { function setText(id, msg, isErr) {
const el = document.getElementById(id); const el = document.getElementById(id);
if (!el) return; if (!el) return;
el.textContent = msg || ""; el.textContent = msg || "";
el.style.color = isErr ? "var(--bad)" : ""; el.style.color = isErr ? "var(--bad)" : "";
} }
function renderRows(tbodyId, items, downloadHrefFn, restoreAttr) { function renderRows(tbodyId, items, downloadHrefFn, restoreAttr) {
const body = document.getElementById(tbodyId); const body = document.getElementById(tbodyId);
if (!body) return; if (!body) return;
if (!items || !items.length) { if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="4" class="muted">暂无备份</td></tr>'; body.innerHTML = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
return; return;
} }
body.innerHTML = items body.innerHTML = items
.map(function (it) { .map(function (it) {
const name = it.name || ""; const name = it.name || "";
const ops = const ops =
'<a class="btn btn-sm" href="' + '<a class="btn btn-sm" href="' +
downloadHrefFn(name) + downloadHrefFn(name) +
'">下载</a>' + '">下载</a>' +
(restoreAttr (restoreAttr
? ' <button type="button" class="btn btn-sm btn-secondary" data-restore-name="' + ? ' <button type="button" class="btn btn-sm btn-secondary" data-restore-name="' +
encodeURIComponent(name) + encodeURIComponent(name) +
'">恢复</button>' '">恢复</button>'
: ""); : "");
return ( return (
"<tr>" + "<tr>" +
"<td><code>" + "<td><code>" +
escapeHtml(name) + escapeHtml(name) +
"</code></td>" + "</code></td>" +
"<td>" + "<td>" +
escapeHtml(it.mtime_bj || "—") + escapeHtml(it.mtime_bj || "—") +
"</td>" + "</td>" +
"<td>" + "<td>" +
escapeHtml(fmtSize(it.size)) + escapeHtml(fmtSize(it.size)) +
"</td>" + "</td>" +
"<td style=\"white-space:nowrap\">" + "<td style=\"white-space:nowrap\">" +
ops + ops +
"</td>" + "</td>" +
"</tr>" "</tr>"
); );
}) })
.join(""); .join("");
} }
async function loadHubList() { async function loadHubList() {
const body = document.getElementById("hub-backup-body"); const body = document.getElementById("hub-backup-body");
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>'; if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
try { try {
const r = await fetch("/api/settings/backup/hub/list", { credentials: "same-origin" }); const r = await fetch("/api/settings/backup/hub/list", { credentials: "same-origin" });
const j = await r.json(); const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载失败"); if (!j.ok) throw new Error(j.msg || "加载失败");
const dir = document.getElementById("hub-backup-dir"); const dir = document.getElementById("hub-backup-dir");
if (dir && j.backup_dir) dir.textContent = j.backup_dir; if (dir && j.backup_dir) dir.textContent = j.backup_dir;
renderRows( renderRows(
"hub-backup-body", "hub-backup-body",
j.items || [], j.items || [],
function (name) { function (name) {
return "/api/settings/backup/hub/download/" + encodeURIComponent(name); return "/api/settings/backup/hub/download/" + encodeURIComponent(name);
}, },
true true
); );
} catch (e) { } catch (e) {
if (body) { if (body) {
body.innerHTML = body.innerHTML =
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>"; '<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
} }
} }
} }
async function createHubBackup() { async function createHubBackup() {
setText("hub-backup-status", "备份中…"); setText("hub-backup-status", "备份中…");
try { try {
const r = await fetch("/api/settings/backup/hub/create", { const r = await fetch("/api/settings/backup/hub/create", {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: "{}", body: "{}",
}); });
const j = await r.json(); const j = await r.json();
if (!j.ok) throw new Error(j.msg || "备份失败"); if (!j.ok) throw new Error(j.msg || "备份失败");
setText("hub-backup-status", "已生成 " + (j.name || "")); setText("hub-backup-status", "已生成 " + (j.name || ""));
await loadHubList(); await loadHubList();
} catch (e) { } catch (e) {
setText("hub-backup-status", e.message || "备份失败", true); setText("hub-backup-status", e.message || "备份失败", true);
} }
} }
async function restoreHub(name) { async function restoreHub(name) {
if (!name) return; if (!name) return;
if (!confirm("确定恢复该中控备份?\n将覆盖 .env 与 hub.db,建议恢复后重启。")) return; if (!confirm("确定恢复该中控备份?\n将覆盖 .env 与 hub.db,建议恢复后重启。")) return;
setText("hub-backup-status", "恢复中…"); setText("hub-backup-status", "恢复中…");
try { try {
const r = await fetch("/api/settings/backup/hub/restore", { const r = await fetch("/api/settings/backup/hub/restore", {
method: "POST", method: "POST",
credentials: "same-origin", credentials: "same-origin",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name }), body: JSON.stringify({ name: name }),
}); });
const j = await r.json(); const j = await r.json();
if (!j.ok) throw new Error(j.msg || "恢复失败"); if (!j.ok) throw new Error(j.msg || "恢复失败");
setText("hub-backup-status", j.msg || "恢复完成"); setText("hub-backup-status", j.msg || "恢复完成");
} catch (e) { } catch (e) {
setText("hub-backup-status", e.message || "恢复失败", true); setText("hub-backup-status", e.message || "恢复失败", true);
} }
} }
async function loadInstances() { async function loadInstances() {
const sel = document.getElementById("client-backup-instance"); const sel = document.getElementById("client-backup-instance");
if (!sel) return; if (!sel) return;
try { try {
const r = await fetch("/api/settings/backup/instances", { credentials: "same-origin" }); const r = await fetch("/api/settings/backup/instances", { credentials: "same-origin" });
const j = await r.json(); const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载实例失败"); if (!j.ok) throw new Error(j.msg || "加载实例失败");
const items = j.items || []; const items = j.items || [];
sel.innerHTML = sel.innerHTML =
'<option value="">选择客户端</option>' + '<option value="">选择客户端</option>' +
items items
.map(function (it) { .map(function (it) {
return ( return (
'<option value="' + '<option value="' +
escapeHtml(String(it.id)) + escapeHtml(String(it.id)) +
'">' + '">' +
escapeHtml(it.name || "#" + it.id) + escapeHtml(it.name || "#" + it.id) +
(it.enabled ? "" : " (停用)") + (it.enabled ? "" : " (停用)") +
"</option>" "</option>"
); );
}) })
.join(""); .join("");
} catch (e) { } catch (e) {
setText("client-backup-status", e.message || "加载实例失败", true); setText("client-backup-status", e.message || "加载实例失败", true);
} }
} }
async function loadClientList() { async function loadClientList() {
const sel = document.getElementById("client-backup-instance"); const sel = document.getElementById("client-backup-instance");
const body = document.getElementById("client-backup-body"); const body = document.getElementById("client-backup-body");
const iid = sel && sel.value; const iid = sel && sel.value;
if (!iid) { if (!iid) {
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">请选择客户端</td></tr>'; if (body) body.innerHTML = '<tr><td colspan="4" class="muted">请选择客户端</td></tr>';
return; return;
} }
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>'; if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
setText("client-backup-status", ""); setText("client-backup-status", "");
try { try {
const r = await fetch("/api/settings/backup/client/" + encodeURIComponent(iid) + "/list", { const r = await fetch("/api/settings/backup/client/" + encodeURIComponent(iid) + "/list", {
credentials: "same-origin", credentials: "same-origin",
}); });
const j = await r.json(); const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载失败"); if (!j.ok) throw new Error(j.msg || "加载失败");
renderRows( renderRows(
"client-backup-body", "client-backup-body",
j.items || [], j.items || [],
function (name) { function (name) {
return ( return (
"/api/settings/backup/client/" + "/api/settings/backup/client/" +
encodeURIComponent(iid) + encodeURIComponent(iid) +
"/download/" + "/download/" +
encodeURIComponent(name) encodeURIComponent(name)
); );
}, },
false false
); );
} catch (e) { } catch (e) {
if (body) { if (body) {
body.innerHTML = body.innerHTML =
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>"; '<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
} }
setText("client-backup-status", e.message || "加载失败", true); setText("client-backup-status", e.message || "加载失败", true);
} }
} }
function boot() { async function uploadHubBackup(file) {
const createBtn = document.getElementById("hub-backup-create"); if (!file) return;
const hubRefresh = document.getElementById("hub-backup-refresh"); setText("hub-backup-status", "上传中…");
const clientRefresh = document.getElementById("client-backup-refresh"); try {
const sel = document.getElementById("client-backup-instance"); const fd = new FormData();
if (createBtn) createBtn.addEventListener("click", createHubBackup); fd.append("file", file, file.name);
if (hubRefresh) hubRefresh.addEventListener("click", loadHubList); const r = await fetch("/api/settings/backup/hub/upload", {
if (clientRefresh) clientRefresh.addEventListener("click", loadClientList); method: "POST",
if (sel) sel.addEventListener("change", loadClientList); credentials: "same-origin",
const hubBody = document.getElementById("hub-backup-body"); body: fd,
if (hubBody) { });
hubBody.addEventListener("click", function (ev) { const j = await r.json();
const btn = ev.target.closest("[data-restore-name]"); if (!j.ok) throw new Error(j.msg || "上传失败");
if (!btn) return; setText("hub-backup-status", j.msg || ("已上传 " + (j.name || "")));
void restoreHub(decodeURIComponent(btn.getAttribute("data-restore-name") || "")); await loadHubList();
}); } catch (e) {
} setText("hub-backup-status", e.message || "上传失败", true);
void loadHubList(); }
void loadInstances(); }
}
function boot() {
global.HubSettingsBackup = { boot: boot }; const createBtn = document.getElementById("hub-backup-create");
})(window); const hubRefresh = document.getElementById("hub-backup-refresh");
const uploadInput = document.getElementById("hub-backup-upload");
const clientRefresh = document.getElementById("client-backup-refresh");
const sel = document.getElementById("client-backup-instance");
if (createBtn) createBtn.addEventListener("click", createHubBackup);
if (hubRefresh) hubRefresh.addEventListener("click", loadHubList);
if (uploadInput) {
uploadInput.addEventListener("change", function () {
const f = uploadInput.files && uploadInput.files[0];
uploadInput.value = "";
void uploadHubBackup(f);
});
}
if (clientRefresh) clientRefresh.addEventListener("click", loadClientList);
if (sel) sel.addEventListener("change", loadClientList);
const hubBody = document.getElementById("hub-backup-body");
if (hubBody) {
hubBody.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-restore-name]");
if (!btn) return;
void restoreHub(decodeURIComponent(btn.getAttribute("data-restore-name") || ""));
});
}
void loadHubList();
void loadInstances();
}
global.HubSettingsBackup = { boot: boot };
})(window);
+6 -2
View File
@@ -103,10 +103,14 @@
<div class="backup-block"> <div class="backup-block">
<div class="backup-block-head"> <div class="backup-block-head">
<h3>中控备份</h3> <h3>中控备份</h3>
<p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code> 与 <code>hub.db</code></p> <p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code> 与 <code>hub.db</code>。换机时先上传家里下载的包,再点恢复。</p>
<div class="settings-actions"> <div class="settings-actions">
<button type="button" class="settings-primary" id="hub-backup-create">立即备份</button> <button type="button" class="settings-primary" id="hub-backup-create">立即备份</button>
<button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button> <button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button>
<label class="btn-secondary" style="cursor:pointer;display:inline-block">
上传备份
<input type="file" id="hub-backup-upload" accept=".gz,.tar.gz,application/gzip" hidden>
</label>
<span class="muted" id="hub-backup-status" style="font-size:.8rem"></span> <span class="muted" id="hub-backup-status" style="font-size:.8rem"></span>
</div> </div>
</div> </div>
@@ -184,7 +188,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=2"></script> <script src="{{ url_for('static', filename='settings_backup.js') }}?v=3"></script>
<script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script> <script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script>
<script> <script>
HubSettingsBackup.boot(); HubSettingsBackup.boot();