中控备份支持上传 tar.gz 后再恢复,便于换机迁移。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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():
|
||||
|
||||
+284
-228
@@ -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()
|
||||
|
||||
+240
-212
@@ -1,212 +1,240 @@
|
||||
(function (global) {
|
||||
function escapeHtml(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.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 = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = items
|
||||
.map(function (it) {
|
||||
const name = it.name || "";
|
||||
const ops =
|
||||
'<a class="btn btn-sm" href="' +
|
||||
downloadHrefFn(name) +
|
||||
'">下载</a>' +
|
||||
(restoreAttr
|
||||
? ' <button type="button" class="btn btn-sm btn-secondary" data-restore-name="' +
|
||||
encodeURIComponent(name) +
|
||||
'">恢复</button>'
|
||||
: "");
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><code>" +
|
||||
escapeHtml(name) +
|
||||
"</code></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.mtime_bj || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtSize(it.size)) +
|
||||
"</td>" +
|
||||
"<td style=\"white-space:nowrap\">" +
|
||||
ops +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadHubList() {
|
||||
const body = document.getElementById("hub-backup-body");
|
||||
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
|
||||
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 =
|
||||
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
'<option value="">选择客户端</option>' +
|
||||
items
|
||||
.map(function (it) {
|
||||
return (
|
||||
'<option value="' +
|
||||
escapeHtml(String(it.id)) +
|
||||
'">' +
|
||||
escapeHtml(it.name || "#" + it.id) +
|
||||
(it.enabled ? "" : " (停用)") +
|
||||
"</option>"
|
||||
);
|
||||
})
|
||||
.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 = '<tr><td colspan="4" class="muted">请选择客户端</td></tr>';
|
||||
return;
|
||||
}
|
||||
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
|
||||
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 =
|
||||
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
|
||||
}
|
||||
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, ">")
|
||||
.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 = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = items
|
||||
.map(function (it) {
|
||||
const name = it.name || "";
|
||||
const ops =
|
||||
'<a class="btn btn-sm" href="' +
|
||||
downloadHrefFn(name) +
|
||||
'">下载</a>' +
|
||||
(restoreAttr
|
||||
? ' <button type="button" class="btn btn-sm btn-secondary" data-restore-name="' +
|
||||
encodeURIComponent(name) +
|
||||
'">恢复</button>'
|
||||
: "");
|
||||
return (
|
||||
"<tr>" +
|
||||
"<td><code>" +
|
||||
escapeHtml(name) +
|
||||
"</code></td>" +
|
||||
"<td>" +
|
||||
escapeHtml(it.mtime_bj || "—") +
|
||||
"</td>" +
|
||||
"<td>" +
|
||||
escapeHtml(fmtSize(it.size)) +
|
||||
"</td>" +
|
||||
"<td style=\"white-space:nowrap\">" +
|
||||
ops +
|
||||
"</td>" +
|
||||
"</tr>"
|
||||
);
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
async function loadHubList() {
|
||||
const body = document.getElementById("hub-backup-body");
|
||||
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
|
||||
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 =
|
||||
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 =
|
||||
'<option value="">选择客户端</option>' +
|
||||
items
|
||||
.map(function (it) {
|
||||
return (
|
||||
'<option value="' +
|
||||
escapeHtml(String(it.id)) +
|
||||
'">' +
|
||||
escapeHtml(it.name || "#" + it.id) +
|
||||
(it.enabled ? "" : " (停用)") +
|
||||
"</option>"
|
||||
);
|
||||
})
|
||||
.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 = '<tr><td colspan="4" class="muted">请选择客户端</td></tr>';
|
||||
return;
|
||||
}
|
||||
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
|
||||
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 =
|
||||
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
|
||||
}
|
||||
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);
|
||||
|
||||
@@ -103,10 +103,14 @@
|
||||
<div class="backup-block">
|
||||
<div class="backup-block-head">
|
||||
<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">
|
||||
<button type="button" class="settings-primary" id="hub-backup-create">立即备份</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -184,7 +188,7 @@
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% 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>
|
||||
HubSettingsBackup.boot();
|
||||
|
||||
Reference in New Issue
Block a user