系统设置增加备份恢复:中控本机备份,并可下载各客户端备份。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,11 +11,13 @@ from pathlib import Path
|
||||
from dotenv import load_dotenv
|
||||
from flask import (
|
||||
Flask,
|
||||
Response,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
send_file,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
@@ -27,8 +29,19 @@ if str(ROOT) not in sys.path:
|
||||
ENV_PATH = ROOT / ".env"
|
||||
load_dotenv(ENV_PATH)
|
||||
|
||||
from lib.backup_lib import (
|
||||
backup_dir as hub_backup_dir,
|
||||
backup_keep_days as hub_backup_keep_days,
|
||||
backup_path as hub_backup_path,
|
||||
create_backup as create_hub_backup,
|
||||
list_backups as list_hub_backups,
|
||||
restore_backup as restore_hub_backup,
|
||||
start_backup_scheduler as start_hub_backup_scheduler,
|
||||
)
|
||||
from lib.bridge_client_lib import (
|
||||
dispatch_cond_order_to_ids,
|
||||
fetch_backup_file,
|
||||
fetch_backup_list,
|
||||
fetch_cond_orders,
|
||||
post_close_full,
|
||||
post_cond_cancel,
|
||||
@@ -36,7 +49,7 @@ from lib.bridge_client_lib import (
|
||||
pull_all_enabled,
|
||||
)
|
||||
from lib.cash_sample_lib import record_cash_samples_from_overview
|
||||
from lib.db_lib import init_db
|
||||
from lib.db_lib import db_path as hub_db_path, init_db
|
||||
from lib.env_lib import apply_env_updates, env_get
|
||||
from lib.health_watch_lib import inspect_overview_rows
|
||||
from lib.instances_lib import (
|
||||
@@ -309,6 +322,116 @@ def settings_page():
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/settings/backup/hub/list", methods=["GET"])
|
||||
@login_required
|
||||
def api_hub_backup_list():
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"items": list_hub_backups(),
|
||||
"backup_dir": hub_backup_dir(),
|
||||
"keep_days": hub_backup_keep_days(),
|
||||
"auto_hour_bj": 3,
|
||||
"includes": [".env", "database"],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _resolved_hub_db_path() -> str:
|
||||
p = hub_db_path()
|
||||
if os.path.isabs(p):
|
||||
return p
|
||||
return str(ROOT / p)
|
||||
|
||||
|
||||
@app.route("/api/settings/backup/hub/create", methods=["POST"])
|
||||
@login_required
|
||||
def api_hub_backup_create():
|
||||
try:
|
||||
result = create_hub_backup(
|
||||
root_dir=str(ROOT),
|
||||
env_path=str(ENV_PATH),
|
||||
db_path=_resolved_hub_db_path(),
|
||||
label="manual",
|
||||
)
|
||||
return jsonify(result)
|
||||
except Exception as exc:
|
||||
return jsonify({"ok": False, "msg": str(exc) or "备份失败"}), 500
|
||||
|
||||
|
||||
@app.route("/api/settings/backup/hub/download/<name>", methods=["GET"])
|
||||
@login_required
|
||||
def api_hub_backup_download(name: str):
|
||||
path = hub_backup_path(name)
|
||||
if not path or not os.path.isfile(path):
|
||||
return jsonify({"ok": False, "msg": "备份不存在"}), 404
|
||||
return send_file(
|
||||
path,
|
||||
mimetype="application/gzip",
|
||||
as_attachment=True,
|
||||
download_name=os.path.basename(path),
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/settings/backup/hub/restore", methods=["POST"])
|
||||
@login_required
|
||||
def api_hub_backup_restore():
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = str(data.get("name") or "").strip()
|
||||
if not name:
|
||||
return jsonify({"ok": False, "msg": "缺少备份文件名"}), 400
|
||||
result = restore_hub_backup(
|
||||
name,
|
||||
root_dir=str(ROOT),
|
||||
env_path=str(ENV_PATH),
|
||||
db_path=_resolved_hub_db_path(),
|
||||
)
|
||||
return jsonify(result), (200 if result.get("ok") else 400)
|
||||
|
||||
|
||||
@app.route("/api/settings/backup/instances", methods=["GET"])
|
||||
@login_required
|
||||
def api_backup_instances():
|
||||
items = [
|
||||
{
|
||||
"id": i["id"],
|
||||
"name": i.get("name") or f"#{i['id']}",
|
||||
"enabled": bool(i.get("enabled")),
|
||||
}
|
||||
for i in list_instances(enabled_only=False)
|
||||
]
|
||||
return jsonify({"ok": True, "items": items})
|
||||
|
||||
|
||||
@app.route("/api/settings/backup/client/<int:iid>/list", methods=["GET"])
|
||||
@login_required
|
||||
def api_client_backup_list(iid: int):
|
||||
inst = get_instance(iid)
|
||||
if not inst:
|
||||
return jsonify({"ok": False, "msg": "实例不存在", "items": []}), 404
|
||||
result = fetch_backup_list(inst)
|
||||
code = 200 if result.get("ok") else 502
|
||||
return jsonify(result), code
|
||||
|
||||
|
||||
@app.route("/api/settings/backup/client/<int:iid>/download/<name>", methods=["GET"])
|
||||
@login_required
|
||||
def api_client_backup_download(iid: int, name: str):
|
||||
inst = get_instance(iid)
|
||||
if not inst:
|
||||
return jsonify({"ok": False, "msg": "实例不存在"}), 404
|
||||
ok, content, meta = fetch_backup_file(inst, name)
|
||||
if not ok or content is None:
|
||||
return jsonify({"ok": False, "msg": meta or "下载失败"}), 502
|
||||
return Response(
|
||||
content,
|
||||
mimetype="application/gzip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="{os.path.basename(meta)}"'
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/desk")
|
||||
@login_required
|
||||
def desk():
|
||||
@@ -369,6 +492,16 @@ def start_health_watch() -> None:
|
||||
|
||||
start_health_watch()
|
||||
|
||||
try:
|
||||
start_hub_backup_scheduler(
|
||||
root_dir=str(ROOT),
|
||||
env_path=str(ENV_PATH),
|
||||
db_path_fn=_resolved_hub_db_path,
|
||||
hour=3,
|
||||
)
|
||||
except Exception as _hub_bak_err:
|
||||
print(f"[backup] scheduler: {_hub_bak_err}")
|
||||
|
||||
|
||||
@app.route("/api/instances/<int:iid>/open_url")
|
||||
@login_required
|
||||
|
||||
@@ -0,0 +1,228 @@
|
||||
"""中控备份/恢复: .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()
|
||||
@@ -269,6 +269,69 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]:
|
||||
return results
|
||||
|
||||
|
||||
def fetch_backup_list(inst: dict[str, Any]) -> dict[str, Any]:
|
||||
base = (inst.get("base_url") or "").rstrip("/")
|
||||
token = (inst.get("bridge_token") or "").strip()
|
||||
if not base or not token:
|
||||
return {"ok": False, "msg": "缺少 URL 或 Token", "items": []}
|
||||
url = f"{base}/api/hub/backup/list"
|
||||
try:
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={"X-Hub-Token": token, "Accept": "application/json"},
|
||||
timeout=_timeout(),
|
||||
)
|
||||
data = r.json() if r.content else {}
|
||||
if isinstance(data, dict):
|
||||
data.setdefault("instance_id", inst.get("id"))
|
||||
data.setdefault("instance_name", inst.get("name"))
|
||||
return data
|
||||
return {"ok": False, "msg": f"HTTP {r.status_code}", "items": []}
|
||||
except Exception as e:
|
||||
return {
|
||||
"ok": False,
|
||||
"msg": str(e) or "拉取失败",
|
||||
"items": [],
|
||||
"instance_id": inst.get("id"),
|
||||
"instance_name": inst.get("name"),
|
||||
}
|
||||
|
||||
|
||||
def fetch_backup_file(inst: dict[str, Any], name: str) -> tuple[bool, bytes | None, str]:
|
||||
"""返回 (ok, content, msg_or_filename)."""
|
||||
base = (inst.get("base_url") or "").rstrip("/")
|
||||
token = (inst.get("bridge_token") or "").strip()
|
||||
safe = (name or "").strip()
|
||||
if not base or not token or not safe:
|
||||
return False, None, "参数不完整"
|
||||
url = f"{base}/api/hub/backup/download/{safe}"
|
||||
try:
|
||||
r = requests.get(
|
||||
url,
|
||||
headers={"X-Hub-Token": token, "Accept": "application/gzip, application/json"},
|
||||
timeout=max(_timeout(), 60.0),
|
||||
stream=True,
|
||||
)
|
||||
ctype = (r.headers.get("content-type") or "").lower()
|
||||
if r.status_code != 200:
|
||||
try:
|
||||
data = r.json()
|
||||
return False, None, (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}"
|
||||
except Exception:
|
||||
return False, None, f"HTTP {r.status_code}"
|
||||
if "json" in ctype:
|
||||
try:
|
||||
data = r.json()
|
||||
return False, None, (data.get("msg") if isinstance(data, dict) else None) or "下载失败"
|
||||
except Exception:
|
||||
return False, None, "下载失败"
|
||||
return True, r.content, os.path.basename(safe)
|
||||
except requests.Timeout:
|
||||
return False, None, "下载超时"
|
||||
except Exception as e:
|
||||
return False, None, str(e) or "下载失败"
|
||||
|
||||
|
||||
def dispatch_cond_order_to_ids(instance_ids: list[int], payload: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
results = []
|
||||
for iid in instance_ids:
|
||||
|
||||
@@ -777,6 +777,37 @@ button, .btn {
|
||||
gap: 10px;
|
||||
margin-top: 2px;
|
||||
}
|
||||
.backup-block-head h3 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.backup-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.backup-table th,
|
||||
.backup-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid rgba(56, 189, 248, 0.12);
|
||||
text-align: left;
|
||||
vertical-align: middle;
|
||||
}
|
||||
.backup-table th {
|
||||
color: var(--muted);
|
||||
font-weight: 550;
|
||||
}
|
||||
.backup-table code {
|
||||
font-size: 0.74rem;
|
||||
word-break: break-all;
|
||||
}
|
||||
.btn-sm {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
font-size: 0.74rem;
|
||||
border-radius: 8px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
.settings-primary,
|
||||
.settings-form button.settings-primary {
|
||||
margin-top: 0;
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
(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);
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}期权中控{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=4">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=5">
|
||||
</head>
|
||||
<body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path == '/' %}page-dashboard{% endif %}">
|
||||
{% if session.get('logged_in') %}
|
||||
|
||||
@@ -74,5 +74,56 @@
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="panel settings-card settings-card--backup" style="grid-column:1/-1">
|
||||
<div class="settings-card-head">
|
||||
<span class="settings-badge">Backup</span>
|
||||
<h2>备份恢复</h2>
|
||||
<p class="muted">每天北京时间 03:00 自动备份,保留最近 10 天。中控可下载本机与各客户端备份;客户端仅能下载自己的。</p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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>
|
||||
<span class="muted" id="hub-backup-status" style="font-size:.8rem"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="backup-table">
|
||||
<thead><tr><th>文件</th><th>时间</th><th>大小</th><th>操作</th></tr></thead>
|
||||
<tbody id="hub-backup-body"><tr><td colspan="4" class="muted">加载中…</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="backup-block" style="margin-top:18px">
|
||||
<div class="backup-block-head">
|
||||
<h3>客户端备份</h3>
|
||||
<p class="muted field-hint">经 Bridge 拉取各机备份列表,可下载到本地</p>
|
||||
<div class="settings-actions">
|
||||
<label class="settings-field" style="margin:0;min-width:12rem">
|
||||
客户端
|
||||
<select id="client-backup-instance"></select>
|
||||
</label>
|
||||
<button type="button" class="btn-secondary" id="client-backup-refresh">刷新</button>
|
||||
<span class="muted" id="client-backup-status" style="font-size:.8rem"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="backup-table">
|
||||
<thead><tr><th>文件</th><th>时间</th><th>大小</th><th>操作</th></tr></thead>
|
||||
<tbody id="client-backup-body"><tr><td colspan="4" class="muted">请选择客户端</td></tr></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
{% endblock %}
|
||||
{% block scripts %}
|
||||
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=1"></script>
|
||||
<script>HubSettingsBackup.boot();</script>
|
||||
{% endblock %}
|
||||
|
||||
Reference in New Issue
Block a user