diff --git a/app.py b/app.py index 4df6cbb..575047b 100644 --- a/app.py +++ b/app.py @@ -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/", 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//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//download/", 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//open_url") @login_required diff --git a/lib/backup_lib.py b/lib/backup_lib.py new file mode 100644 index 0000000..8bfedbb --- /dev/null +++ b/lib/backup_lib.py @@ -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() diff --git a/lib/bridge_client_lib.py b/lib/bridge_client_lib.py index 3b278c5..bbfcf22 100644 --- a/lib/bridge_client_lib.py +++ b/lib/bridge_client_lib.py @@ -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: diff --git a/static/hub.css b/static/hub.css index 0a1ad5a..a014bf3 100644 --- a/static/hub.css +++ b/static/hub.css @@ -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; diff --git a/static/settings_backup.js b/static/settings_backup.js new file mode 100644 index 0000000..12418d3 --- /dev/null +++ b/static/settings_backup.js @@ -0,0 +1,212 @@ +(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); diff --git a/templates/base.html b/templates/base.html index 5ddf24d..32bec2f 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@ {% block title %}期权中控{% endblock %} - + {% if session.get('logged_in') %} diff --git a/templates/settings.html b/templates/settings.html index 15a7d9f..e6463f2 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -74,5 +74,56 @@ + +
+
+ Backup +

备份恢复

+

每天北京时间 03:00 自动备份,保留最近 10 天。中控可下载本机与各客户端备份;客户端仅能下载自己的。

+
+ +
+
+

中控备份

+

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

+
+ + + +
+
+
+ + + +
文件时间大小操作
加载中…
+
+
+ +
+
+

客户端备份

+

经 Bridge 拉取各机备份列表,可下载到本地

+
+ + + +
+
+
+ + + +
文件时间大小操作
请选择客户端
+
+
+
{% endblock %} +{% block scripts %} + + +{% endblock %}