From 02ca76ea7c4b619e7c293d3d3c59d7455447661e Mon Sep 17 00:00:00 2001 From: dekun Date: Mon, 5 Oct 2026 18:45:37 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=AD=E6=8E=A7=E5=A4=87=E4=BB=BD=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E4=B8=8A=E4=BC=A0=20tar.gz=20=E5=90=8E=E5=86=8D?= =?UTF-8?q?=E6=81=A2=E5=A4=8D=EF=BC=8C=E4=BE=BF=E4=BA=8E=E6=8D=A2=E6=9C=BA?= =?UTF-8?q?=E8=BF=81=E7=A7=BB=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 11 + lib/backup_lib.py | 512 +++++++++++++++++++++----------------- static/settings_backup.js | 452 +++++++++++++++++---------------- templates/settings.html | 8 +- 4 files changed, 541 insertions(+), 442 deletions(-) diff --git a/app.py b/app.py index 0313226..55ff4dd 100644 --- a/app.py +++ b/app.py @@ -36,6 +36,7 @@ from lib.backup_lib import ( create_backup as create_hub_backup, list_backups as list_hub_backups, restore_backup as restore_hub_backup, + save_uploaded_backup as save_hub_uploaded_backup, start_backup_scheduler as start_hub_backup_scheduler, ) 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) +@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"]) @login_required def api_backup_instances(): diff --git a/lib/backup_lib.py b/lib/backup_lib.py index 8bfedbb..b77c734 100644 --- a/lib/backup_lib.py +++ b/lib/backup_lib.py @@ -1,228 +1,284 @@ -"""中控备份/恢复: .env + hub.db;默认落盘 /root/crypto_okx_hub_backups.""" -from __future__ import annotations - -import json -import os -import re -import shutil -import tarfile -import tempfile -import threading -import time -from datetime import datetime, timedelta, timezone -from typing import Any, Callable - -_BJ = timezone(timedelta(hours=8)) -_SAFE_NAME_RE = re.compile(r"^crypto_okx_hub_backup_\d{8}_\d{6}\.tar\.gz$") -_LOCK = threading.Lock() -_LAST_AUTO_DAY = "" - - -def beijing_now() -> datetime: - return datetime.now(_BJ) - - -def backup_keep_days() -> int: - try: - return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10)) - except (TypeError, ValueError): - return 10 - - -def backup_dir() -> str: - root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root" - path = os.path.join(root, "crypto_okx_hub_backups") - os.makedirs(path, exist_ok=True) - return path - - -def _safe_backup_name(name: str) -> str | None: - n = os.path.basename((name or "").strip()) - if not _SAFE_NAME_RE.match(n): - return None - return n - - -def backup_path(name: str) -> str | None: - safe = _safe_backup_name(name) - if not safe: - return None - path = os.path.abspath(os.path.join(backup_dir(), safe)) - root = os.path.abspath(backup_dir()) - if not path.startswith(root + os.sep) and path != root: - return None - return path - - -def list_backups() -> list[dict[str, Any]]: - root = backup_dir() - items: list[dict[str, Any]] = [] - try: - names = os.listdir(root) - except OSError: - return [] - for name in names: - if not _SAFE_NAME_RE.match(name): - continue - path = os.path.join(root, name) - try: - st = os.stat(path) - except OSError: - continue - items.append( - { - "name": name, - "size": int(st.st_size), - "mtime": int(st.st_mtime), - "mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime( - "%Y-%m-%d %H:%M:%S" - ), - } - ) - items.sort(key=lambda x: x["mtime"], reverse=True) - return items - - -def prune_backups(*, keep_days: int | None = None) -> int: - days = backup_keep_days() if keep_days is None else max(1, int(keep_days)) - cutoff = time.time() - days * 86400 - removed = 0 - for item in list_backups(): - if float(item["mtime"]) >= cutoff: - continue - path = backup_path(item["name"]) - if not path: - continue - try: - os.remove(path) - removed += 1 - except OSError: - pass - return removed - - -def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]: - with _LOCK: - now = beijing_now() - name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz" - out_path = os.path.join(backup_dir(), name) - manifest = { - "kind": "crypto_okx_hub", - "created_bj": now.strftime("%Y-%m-%d %H:%M:%S"), - "label": (label or "manual").strip() or "manual", - "includes": [".env", "database"], - } - tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_") - os.close(tmp_fd) - try: - with tarfile.open(tmp_path, "w:gz") as tar: - manifest_path = tmp_path + ".manifest.json" - with open(manifest_path, "w", encoding="utf-8") as f: - json.dump(manifest, f, ensure_ascii=False, indent=2) - try: - tar.add(manifest_path, arcname="manifest.json") - finally: - try: - os.remove(manifest_path) - except OSError: - pass - if env_path and os.path.isfile(env_path): - tar.add(env_path, arcname=".env") - if db_path and os.path.isfile(db_path): - tar.add(db_path, arcname="database/" + os.path.basename(db_path)) - shutil.move(tmp_path, out_path) - except Exception: - try: - os.remove(tmp_path) - except OSError: - pass - raise - prune_backups() - st = os.stat(out_path) - return { - "ok": True, - "name": name, - "path": out_path, - "size": int(st.st_size), - "mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"), - "keep_days": backup_keep_days(), - } - - -def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]: - path = backup_path(name) - if not path or not os.path.isfile(path): - return {"ok": False, "msg": "备份不存在"} - with _LOCK: - with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp: - with tarfile.open(path, "r:gz") as tar: - tar.extractall(tmp) - env_src = os.path.join(tmp, ".env") - if os.path.isfile(env_src) and env_path: - shutil.copy2(env_src, env_path) - db_src_dir = os.path.join(tmp, "database") - if os.path.isdir(db_src_dir) and db_path: - for fn in os.listdir(db_src_dir): - src = os.path.join(db_src_dir, fn) - if os.path.isfile(src): - os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True) - shutil.copy2(src, db_path) - break - return { - "ok": True, - "msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效", - "name": os.path.basename(path), - } - - -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() +"""中控备份/恢复: .env + hub.db;默认落盘 /root/crypto_okx_hub_backups.""" +from __future__ import annotations + +import json +import os +import re +import shutil +import tarfile +import tempfile +import threading +import time +from datetime import datetime, timedelta, timezone +from typing import Any, Callable + +_BJ = timezone(timedelta(hours=8)) +_SAFE_NAME_RE = re.compile(r"^crypto_okx_hub_backup_\d{8}_\d{6}\.tar\.gz$") +_LOCK = threading.Lock() +_LAST_AUTO_DAY = "" + + +def beijing_now() -> datetime: + return datetime.now(_BJ) + + +def backup_keep_days() -> int: + try: + return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10)) + except (TypeError, ValueError): + return 10 + + +def backup_dir() -> str: + root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root" + path = os.path.join(root, "crypto_okx_hub_backups") + os.makedirs(path, exist_ok=True) + return path + + +def _safe_backup_name(name: str) -> str | None: + n = os.path.basename((name or "").strip()) + if not _SAFE_NAME_RE.match(n): + return None + return n + + +def backup_path(name: str) -> str | None: + safe = _safe_backup_name(name) + if not safe: + return None + path = os.path.abspath(os.path.join(backup_dir(), safe)) + root = os.path.abspath(backup_dir()) + if not path.startswith(root + os.sep) and path != root: + return None + return path + + +def list_backups() -> list[dict[str, Any]]: + root = backup_dir() + items: list[dict[str, Any]] = [] + try: + names = os.listdir(root) + except OSError: + return [] + for name in names: + if not _SAFE_NAME_RE.match(name): + continue + path = os.path.join(root, name) + try: + st = os.stat(path) + except OSError: + continue + items.append( + { + "name": name, + "size": int(st.st_size), + "mtime": int(st.st_mtime), + "mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime( + "%Y-%m-%d %H:%M:%S" + ), + } + ) + items.sort(key=lambda x: x["mtime"], reverse=True) + return items + + +def prune_backups(*, keep_days: int | None = None) -> int: + days = backup_keep_days() if keep_days is None else max(1, int(keep_days)) + cutoff = time.time() - days * 86400 + removed = 0 + for item in list_backups(): + if float(item["mtime"]) >= cutoff: + continue + path = backup_path(item["name"]) + if not path: + continue + try: + os.remove(path) + removed += 1 + except OSError: + pass + return removed + + +def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]: + with _LOCK: + now = beijing_now() + name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz" + out_path = os.path.join(backup_dir(), name) + manifest = { + "kind": "crypto_okx_hub", + "created_bj": now.strftime("%Y-%m-%d %H:%M:%S"), + "label": (label or "manual").strip() or "manual", + "includes": [".env", "database"], + } + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_") + os.close(tmp_fd) + try: + with tarfile.open(tmp_path, "w:gz") as tar: + manifest_path = tmp_path + ".manifest.json" + with open(manifest_path, "w", encoding="utf-8") as f: + json.dump(manifest, f, ensure_ascii=False, indent=2) + try: + tar.add(manifest_path, arcname="manifest.json") + finally: + try: + os.remove(manifest_path) + except OSError: + pass + if env_path and os.path.isfile(env_path): + tar.add(env_path, arcname=".env") + if db_path and os.path.isfile(db_path): + tar.add(db_path, arcname="database/" + os.path.basename(db_path)) + shutil.move(tmp_path, out_path) + except Exception: + try: + os.remove(tmp_path) + except OSError: + pass + raise + prune_backups() + st = os.stat(out_path) + return { + "ok": True, + "name": name, + "path": out_path, + "size": int(st.st_size), + "mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"), + "keep_days": backup_keep_days(), + } + + +def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]: + path = backup_path(name) + if not path or not os.path.isfile(path): + return {"ok": False, "msg": "备份不存在"} + with _LOCK: + with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp: + with tarfile.open(path, "r:gz") as tar: + tar.extractall(tmp) + env_src = os.path.join(tmp, ".env") + if os.path.isfile(env_src) and env_path: + shutil.copy2(env_src, env_path) + db_src_dir = os.path.join(tmp, "database") + if os.path.isdir(db_src_dir) and db_path: + for fn in os.listdir(db_src_dir): + src = os.path.join(db_src_dir, fn) + if os.path.isfile(src): + os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True) + shutil.copy2(src, db_path) + break + return { + "ok": True, + "msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效", + "name": os.path.basename(path), + } + + +_MAX_UPLOAD_BYTES = 32 * 1024 * 1024 + + +def save_uploaded_backup(file_storage: Any) -> dict[str, Any]: + """把本机下载的中控备份包写入备份目录,不自动恢复.""" + filename = os.path.basename(str(getattr(file_storage, "filename", "") or "").strip()) + safe = _safe_backup_name(filename) + if not safe: + return { + "ok": False, + "msg": "文件名须为 crypto_okx_hub_backup_YYYYMMDD_HHMMSS.tar.gz", + } + dest = os.path.join(backup_dir(), safe) + tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_up_") + os.close(tmp_fd) + try: + file_storage.save(tmp_path) + total = os.path.getsize(tmp_path) + if total <= 0: + os.remove(tmp_path) + return {"ok": False, "msg": "上传文件为空"} + if total > _MAX_UPLOAD_BYTES: + os.remove(tmp_path) + return {"ok": False, "msg": "备份文件超过 32MB"} + with tarfile.open(tmp_path, "r:gz") as tar: + names = tar.getnames() + has_env = any(n.replace("\\", "/").rstrip("/") in (".env", "./.env") for n in names) + has_db = any( + n.replace("\\", "/").startswith("database/") and n.replace("\\", "/") != "database/" + for n in names + ) + if not has_env and not has_db: + os.remove(tmp_path) + return {"ok": False, "msg": "不是中控备份包(缺少 .env / database)"} + shutil.move(tmp_path, dest) + except tarfile.TarError: + try: + os.remove(tmp_path) + except OSError: + pass + return {"ok": False, "msg": "无法读取 tar.gz"} + except Exception as exc: + try: + os.remove(tmp_path) + except OSError: + pass + return {"ok": False, "msg": str(exc) or "上传失败"} + st = os.stat(dest) + return { + "ok": True, + "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() diff --git a/static/settings_backup.js b/static/settings_backup.js index 12418d3..15e9702 100644 --- a/static/settings_backup.js +++ b/static/settings_backup.js @@ -1,212 +1,240 @@ -(function (global) { - function escapeHtml(s) { - return String(s) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); - } - - function fmtSize(n) { - const v = Number(n) || 0; - if (v < 1024) return v + " B"; - if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB"; - return (v / (1024 * 1024)).toFixed(2) + " MB"; - } - - function setText(id, msg, isErr) { - const el = document.getElementById(id); - if (!el) return; - el.textContent = msg || ""; - el.style.color = isErr ? "var(--bad)" : ""; - } - - function renderRows(tbodyId, items, downloadHrefFn, restoreAttr) { - const body = document.getElementById(tbodyId); - if (!body) return; - if (!items || !items.length) { - body.innerHTML = '暂无备份'; - return; - } - body.innerHTML = items - .map(function (it) { - const name = it.name || ""; - const ops = - '下载' + - (restoreAttr - ? ' ' - : ""); - return ( - "" + - "" + - escapeHtml(name) + - "" + - "" + - escapeHtml(it.mtime_bj || "—") + - "" + - "" + - escapeHtml(fmtSize(it.size)) + - "" + - "" + - ops + - "" + - "" - ); - }) - .join(""); - } - - async function loadHubList() { - const body = document.getElementById("hub-backup-body"); - if (body) body.innerHTML = '加载中…'; - try { - const r = await fetch("/api/settings/backup/hub/list", { credentials: "same-origin" }); - const j = await r.json(); - if (!j.ok) throw new Error(j.msg || "加载失败"); - const dir = document.getElementById("hub-backup-dir"); - if (dir && j.backup_dir) dir.textContent = j.backup_dir; - renderRows( - "hub-backup-body", - j.items || [], - function (name) { - return "/api/settings/backup/hub/download/" + encodeURIComponent(name); - }, - true - ); - } catch (e) { - if (body) { - body.innerHTML = - '' + escapeHtml(e.message || "加载失败") + ""; - } - } - } - - async function createHubBackup() { - setText("hub-backup-status", "备份中…"); - try { - const r = await fetch("/api/settings/backup/hub/create", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: "{}", - }); - const j = await r.json(); - if (!j.ok) throw new Error(j.msg || "备份失败"); - setText("hub-backup-status", "已生成 " + (j.name || "")); - await loadHubList(); - } catch (e) { - setText("hub-backup-status", e.message || "备份失败", true); - } - } - - async function restoreHub(name) { - if (!name) return; - if (!confirm("确定恢复该中控备份?\n将覆盖 .env 与 hub.db,建议恢复后重启。")) return; - setText("hub-backup-status", "恢复中…"); - try { - const r = await fetch("/api/settings/backup/hub/restore", { - method: "POST", - credentials: "same-origin", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ name: name }), - }); - const j = await r.json(); - if (!j.ok) throw new Error(j.msg || "恢复失败"); - setText("hub-backup-status", j.msg || "恢复完成"); - } catch (e) { - setText("hub-backup-status", e.message || "恢复失败", true); - } - } - - async function loadInstances() { - const sel = document.getElementById("client-backup-instance"); - if (!sel) return; - try { - const r = await fetch("/api/settings/backup/instances", { credentials: "same-origin" }); - const j = await r.json(); - if (!j.ok) throw new Error(j.msg || "加载实例失败"); - const items = j.items || []; - sel.innerHTML = - '' + - items - .map(function (it) { - return ( - '" - ); - }) - .join(""); - } catch (e) { - setText("client-backup-status", e.message || "加载实例失败", true); - } - } - - async function loadClientList() { - const sel = document.getElementById("client-backup-instance"); - const body = document.getElementById("client-backup-body"); - const iid = sel && sel.value; - if (!iid) { - if (body) body.innerHTML = '请选择客户端'; - return; - } - if (body) body.innerHTML = '加载中…'; - setText("client-backup-status", ""); - try { - const r = await fetch("/api/settings/backup/client/" + encodeURIComponent(iid) + "/list", { - credentials: "same-origin", - }); - const j = await r.json(); - if (!j.ok) throw new Error(j.msg || "加载失败"); - renderRows( - "client-backup-body", - j.items || [], - function (name) { - return ( - "/api/settings/backup/client/" + - encodeURIComponent(iid) + - "/download/" + - encodeURIComponent(name) - ); - }, - false - ); - } catch (e) { - if (body) { - body.innerHTML = - '' + escapeHtml(e.message || "加载失败") + ""; - } - setText("client-backup-status", e.message || "加载失败", true); - } - } - - function boot() { - const createBtn = document.getElementById("hub-backup-create"); - const hubRefresh = document.getElementById("hub-backup-refresh"); - 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 (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); +(function (global) { + function escapeHtml(s) { + return String(s) + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); + } + + function fmtSize(n) { + const v = Number(n) || 0; + if (v < 1024) return v + " B"; + if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB"; + return (v / (1024 * 1024)).toFixed(2) + " MB"; + } + + function setText(id, msg, isErr) { + const el = document.getElementById(id); + if (!el) return; + el.textContent = msg || ""; + el.style.color = isErr ? "var(--bad)" : ""; + } + + function renderRows(tbodyId, items, downloadHrefFn, restoreAttr) { + const body = document.getElementById(tbodyId); + if (!body) return; + if (!items || !items.length) { + body.innerHTML = '暂无备份'; + return; + } + body.innerHTML = items + .map(function (it) { + const name = it.name || ""; + const ops = + '下载' + + (restoreAttr + ? ' ' + : ""); + return ( + "" + + "" + + escapeHtml(name) + + "" + + "" + + escapeHtml(it.mtime_bj || "—") + + "" + + "" + + escapeHtml(fmtSize(it.size)) + + "" + + "" + + ops + + "" + + "" + ); + }) + .join(""); + } + + async function loadHubList() { + const body = document.getElementById("hub-backup-body"); + if (body) body.innerHTML = '加载中…'; + try { + const r = await fetch("/api/settings/backup/hub/list", { credentials: "same-origin" }); + const j = await r.json(); + if (!j.ok) throw new Error(j.msg || "加载失败"); + const dir = document.getElementById("hub-backup-dir"); + if (dir && j.backup_dir) dir.textContent = j.backup_dir; + renderRows( + "hub-backup-body", + j.items || [], + function (name) { + return "/api/settings/backup/hub/download/" + encodeURIComponent(name); + }, + true + ); + } catch (e) { + if (body) { + body.innerHTML = + '' + escapeHtml(e.message || "加载失败") + ""; + } + } + } + + async function createHubBackup() { + setText("hub-backup-status", "备份中…"); + try { + const r = await fetch("/api/settings/backup/hub/create", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + const j = await r.json(); + if (!j.ok) throw new Error(j.msg || "备份失败"); + setText("hub-backup-status", "已生成 " + (j.name || "")); + await loadHubList(); + } catch (e) { + setText("hub-backup-status", e.message || "备份失败", true); + } + } + + async function restoreHub(name) { + if (!name) return; + if (!confirm("确定恢复该中控备份?\n将覆盖 .env 与 hub.db,建议恢复后重启。")) return; + setText("hub-backup-status", "恢复中…"); + try { + const r = await fetch("/api/settings/backup/hub/restore", { + method: "POST", + credentials: "same-origin", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name: name }), + }); + const j = await r.json(); + if (!j.ok) throw new Error(j.msg || "恢复失败"); + setText("hub-backup-status", j.msg || "恢复完成"); + } catch (e) { + setText("hub-backup-status", e.message || "恢复失败", true); + } + } + + async function loadInstances() { + const sel = document.getElementById("client-backup-instance"); + if (!sel) return; + try { + const r = await fetch("/api/settings/backup/instances", { credentials: "same-origin" }); + const j = await r.json(); + if (!j.ok) throw new Error(j.msg || "加载实例失败"); + const items = j.items || []; + sel.innerHTML = + '' + + items + .map(function (it) { + return ( + '" + ); + }) + .join(""); + } catch (e) { + setText("client-backup-status", e.message || "加载实例失败", true); + } + } + + async function loadClientList() { + const sel = document.getElementById("client-backup-instance"); + const body = document.getElementById("client-backup-body"); + const iid = sel && sel.value; + if (!iid) { + if (body) body.innerHTML = '请选择客户端'; + return; + } + if (body) body.innerHTML = '加载中…'; + setText("client-backup-status", ""); + try { + const r = await fetch("/api/settings/backup/client/" + encodeURIComponent(iid) + "/list", { + credentials: "same-origin", + }); + const j = await r.json(); + if (!j.ok) throw new Error(j.msg || "加载失败"); + renderRows( + "client-backup-body", + j.items || [], + function (name) { + return ( + "/api/settings/backup/client/" + + encodeURIComponent(iid) + + "/download/" + + encodeURIComponent(name) + ); + }, + false + ); + } catch (e) { + if (body) { + body.innerHTML = + '' + escapeHtml(e.message || "加载失败") + ""; + } + setText("client-backup-status", e.message || "加载失败", true); + } + } + + async function uploadHubBackup(file) { + if (!file) return; + setText("hub-backup-status", "上传中…"); + try { + const fd = new FormData(); + fd.append("file", file, file.name); + const r = await fetch("/api/settings/backup/hub/upload", { + method: "POST", + credentials: "same-origin", + body: fd, + }); + const j = await r.json(); + if (!j.ok) throw new Error(j.msg || "上传失败"); + setText("hub-backup-status", j.msg || ("已上传 " + (j.name || ""))); + await loadHubList(); + } catch (e) { + setText("hub-backup-status", e.message || "上传失败", true); + } + } + + function boot() { + const createBtn = document.getElementById("hub-backup-create"); + 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); diff --git a/templates/settings.html b/templates/settings.html index a5acb30..0930904 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -103,10 +103,14 @@

中控备份

-

目录 /root/crypto_okx_hub_backups · 含 .env 与 hub.db

+

目录 /root/crypto_okx_hub_backups · 含 .env 与 hub.db。换机时先上传家里下载的包,再点恢复。

+
@@ -184,7 +188,7 @@
{% endblock %} {% block scripts %} - +