feat: 系统设置增加备份恢复与默认登录 admin
支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -16,6 +16,9 @@
|
||||
**/.env.bak
|
||||
**/.env.local
|
||||
manual_trading_hub/hub_settings.json
|
||||
manual_trading_hub/hub_backup_state.json
|
||||
manual_trading_hub/hub_fund_history.json
|
||||
manual_trading_hub/hub_supervisor_state.json
|
||||
manual_trading_hub/hub_ai_summaries.json
|
||||
manual_trading_hub/hub_ai_chat.json
|
||||
manual_trading_hub/hub_ai_fund_history.json
|
||||
|
||||
@@ -21,9 +21,9 @@ APP_PORT=5001
|
||||
APP_DEBUG=false
|
||||
|
||||
# 登录账号
|
||||
APP_USERNAME=dekun
|
||||
APP_USERNAME=admin
|
||||
# 登录密码(请改成你自己的强密码)
|
||||
APP_PASSWORD=ChangeMe123!
|
||||
APP_PASSWORD=admin123
|
||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||
APP_AUTH_DISABLED=true
|
||||
# --- 多账户交易中控 manual_trading_hub ---
|
||||
|
||||
@@ -21,9 +21,9 @@ APP_PORT=5000
|
||||
APP_DEBUG=false
|
||||
|
||||
# 登录账号
|
||||
APP_USERNAME=dekun
|
||||
APP_USERNAME=admin
|
||||
# 登录密码(请改成你自己的强密码)
|
||||
APP_PASSWORD=ChangeMe123!
|
||||
APP_PASSWORD=admin123
|
||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||
APP_AUTH_DISABLED=true
|
||||
# --- 多账户交易中控 manual_trading_hub ---
|
||||
|
||||
@@ -21,9 +21,9 @@ APP_PORT=5002
|
||||
APP_DEBUG=false
|
||||
|
||||
# 登录账号
|
||||
APP_USERNAME=dekun
|
||||
APP_USERNAME=admin
|
||||
# 登录密码(请改成你自己的强密码)
|
||||
APP_PASSWORD=ChangeMe123!
|
||||
APP_PASSWORD=admin123
|
||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||
APP_AUTH_DISABLED=true
|
||||
# --- 多账户交易中控 manual_trading_hub ---
|
||||
|
||||
@@ -21,9 +21,9 @@ APP_PORT=5004
|
||||
APP_DEBUG=false
|
||||
|
||||
# 登录账号
|
||||
APP_USERNAME=dekun
|
||||
APP_USERNAME=admin
|
||||
# 登录密码(请改成你自己的强密码)
|
||||
APP_PASSWORD=ChangeMe123!
|
||||
APP_PASSWORD=admin123
|
||||
# 是否关闭登录校验(局域网可设 true;公网务必 false)
|
||||
APP_AUTH_DISABLED=true
|
||||
# --- 多账户交易中控 manual_trading_hub ---
|
||||
|
||||
@@ -0,0 +1,447 @@
|
||||
"""中控备份与恢复:四所 SQLite、K 线库、env、hub JSON。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Optional
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
from lib.paths import REPO_ROOT, hub_data_dir, manual_trading_hub_dir
|
||||
|
||||
HUB_DIR = manual_trading_hub_dir()
|
||||
TZ_NAME = (os.getenv("HUB_BACKUP_TZ") or "Asia/Shanghai").strip() or "Asia/Shanghai"
|
||||
|
||||
EXCHANGE_DIRS: list[tuple[str, str]] = [
|
||||
("binance", "crypto_monitor_binance"),
|
||||
("okx", "crypto_monitor_okx"),
|
||||
("gate", "crypto_monitor_gate"),
|
||||
("gate_bot", "crypto_monitor_gate_bot"),
|
||||
]
|
||||
|
||||
HUB_JSON_FILES = (
|
||||
"hub_settings.json",
|
||||
"hub_fund_history.json",
|
||||
"hub_ai_summaries.json",
|
||||
"hub_ai_chat.json",
|
||||
"hub_supervisor_state.json",
|
||||
)
|
||||
|
||||
HUB_DATA_FILES = (
|
||||
"hub_kline.db",
|
||||
"hub_symbol_archive.db",
|
||||
"hub_entry_plans.db",
|
||||
"hub_macro_calendar.db",
|
||||
"hub_volume_rank.json",
|
||||
)
|
||||
|
||||
DEFAULT_BACKUP_SETTINGS = {
|
||||
"auto_enabled": True,
|
||||
"auto_hour": 0,
|
||||
"retention_days": 30,
|
||||
"include_env": True,
|
||||
"include_exchange_images": False,
|
||||
"backup_root": "",
|
||||
}
|
||||
|
||||
BACKUP_STATE_PATH = HUB_DIR / "hub_backup_state.json"
|
||||
|
||||
|
||||
def normalize_backup_settings(raw: dict | None) -> dict:
|
||||
out = dict(DEFAULT_BACKUP_SETTINGS)
|
||||
if isinstance(raw, dict):
|
||||
for key in DEFAULT_BACKUP_SETTINGS:
|
||||
if key in raw:
|
||||
out[key] = raw[key]
|
||||
try:
|
||||
out["auto_hour"] = max(0, min(23, int(out.get("auto_hour", 0))))
|
||||
except (TypeError, ValueError):
|
||||
out["auto_hour"] = 0
|
||||
try:
|
||||
out["retention_days"] = max(1, min(365, int(out.get("retention_days", 30))))
|
||||
except (TypeError, ValueError):
|
||||
out["retention_days"] = 30
|
||||
out["auto_enabled"] = bool(out.get("auto_enabled"))
|
||||
out["include_env"] = bool(out.get("include_env", True))
|
||||
out["include_exchange_images"] = bool(out.get("include_exchange_images"))
|
||||
out["backup_root"] = str(out.get("backup_root") or "").strip()
|
||||
return out
|
||||
|
||||
|
||||
def backup_root(settings: dict | None = None) -> Path:
|
||||
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||
raw = cfg.get("backup_root") or (os.getenv("HUB_BACKUP_ROOT") or "").strip()
|
||||
if not raw:
|
||||
raw = (os.getenv("BACKUP_ROOT") or "/root/backups").strip()
|
||||
root = Path(raw).expanduser()
|
||||
if not root.is_absolute():
|
||||
root = REPO_ROOT / root
|
||||
portal = root / "crypto_monitor_portal"
|
||||
portal.mkdir(parents=True, exist_ok=True)
|
||||
return portal
|
||||
|
||||
|
||||
def _now_local() -> datetime:
|
||||
try:
|
||||
return datetime.now(ZoneInfo(TZ_NAME))
|
||||
except Exception:
|
||||
return datetime.now()
|
||||
|
||||
|
||||
def _read_env_var(env_path: Path, key: str, default: str = "") -> str:
|
||||
if not env_path.is_file():
|
||||
return default
|
||||
try:
|
||||
for line in env_path.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
raw = line.strip()
|
||||
if not raw or raw.startswith("#") or "=" not in raw:
|
||||
continue
|
||||
k, v = raw.split("=", 1)
|
||||
if k.strip() == key:
|
||||
return v.strip().strip('"').strip("'")
|
||||
except Exception:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _resolve_project_path(project_dir: Path, rel: str) -> Path:
|
||||
p = Path(rel or "")
|
||||
if p.is_absolute():
|
||||
return p
|
||||
return project_dir / p
|
||||
|
||||
|
||||
def _load_backup_state() -> dict:
|
||||
if not BACKUP_STATE_PATH.is_file():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(BACKUP_STATE_PATH.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _save_backup_state(state: dict) -> None:
|
||||
BACKUP_STATE_PATH.write_text(
|
||||
json.dumps(state, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _safe_archive_name(name: str) -> bool:
|
||||
return bool(re.fullmatch(r"backup_[0-9]{4}-[0-9]{2}-[0-9]{2}_[0-9]{6}\.zip", name or ""))
|
||||
|
||||
|
||||
def _collect_targets(
|
||||
*,
|
||||
include_env: bool,
|
||||
include_exchange_images: bool,
|
||||
) -> list[tuple[str, Path, str]]:
|
||||
"""Return list of (archive_rel_path, source_path, kind)."""
|
||||
items: list[tuple[str, Path, str]] = []
|
||||
|
||||
if include_env:
|
||||
hub_env = HUB_DIR / ".env"
|
||||
if hub_env.is_file():
|
||||
items.append(("hub/.env", hub_env, "env"))
|
||||
|
||||
for name in HUB_JSON_FILES:
|
||||
src = HUB_DIR / name
|
||||
if src.is_file():
|
||||
items.append((f"hub/{name}", src, "json"))
|
||||
|
||||
data_dir = hub_data_dir()
|
||||
for name in HUB_DATA_FILES:
|
||||
src = data_dir / name
|
||||
if src.is_file():
|
||||
items.append((f"hub/data/{name}", src, "sqlite" if name.endswith(".db") else "json"))
|
||||
|
||||
for key, dirname in EXCHANGE_DIRS:
|
||||
proj = REPO_ROOT / dirname
|
||||
prefix = dirname
|
||||
env_path = proj / ".env"
|
||||
db_rel = "crypto.db"
|
||||
upload_rel = "static/images"
|
||||
if env_path.is_file():
|
||||
db_rel = _read_env_var(env_path, "DB_PATH", "crypto.db") or "crypto.db"
|
||||
upload_rel = _read_env_var(env_path, "UPLOAD_DIR", "static/images") or "static/images"
|
||||
if include_env:
|
||||
items.append((f"{prefix}/.env", env_path, "env"))
|
||||
db_path = _resolve_project_path(proj, db_rel)
|
||||
if db_path.is_file():
|
||||
items.append((f"{prefix}/{db_rel}", db_path, "sqlite"))
|
||||
if include_exchange_images:
|
||||
img_dir = _resolve_project_path(proj, upload_rel)
|
||||
if img_dir.is_dir():
|
||||
for fp in sorted(img_dir.rglob("*")):
|
||||
if fp.is_file():
|
||||
rel = fp.relative_to(proj).as_posix()
|
||||
items.append((f"{prefix}/{rel}", fp, "image"))
|
||||
return items
|
||||
|
||||
|
||||
def _write_manifest(staging: Path, trigger: str, files: list[dict]) -> None:
|
||||
manifest = {
|
||||
"version": 1,
|
||||
"created_at": _now_local().strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"timezone": TZ_NAME,
|
||||
"trigger": trigger,
|
||||
"repo_root": str(REPO_ROOT),
|
||||
"files": files,
|
||||
}
|
||||
(staging / "manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def run_backup(
|
||||
*,
|
||||
trigger: str = "manual",
|
||||
settings: dict | None = None,
|
||||
log_fn: Callable[[str], None] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||
root = backup_root(settings)
|
||||
ts = _now_local().strftime("%Y-%m-%d_%H%M%S")
|
||||
archive_name = f"backup_{ts}.zip"
|
||||
archive_path = root / archive_name
|
||||
|
||||
def log(msg: str) -> None:
|
||||
if log_fn:
|
||||
log_fn(msg)
|
||||
|
||||
targets = _collect_targets(
|
||||
include_env=cfg["include_env"],
|
||||
include_exchange_images=cfg["include_exchange_images"],
|
||||
)
|
||||
if not targets:
|
||||
return {"ok": False, "error": "没有可备份的文件"}
|
||||
|
||||
file_meta: list[dict] = []
|
||||
with tempfile.TemporaryDirectory(prefix="hub_backup_") as tmp:
|
||||
staging = Path(tmp)
|
||||
for arc_rel, src, kind in targets:
|
||||
dest = staging / arc_rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(src, dest)
|
||||
file_meta.append(
|
||||
{
|
||||
"path": arc_rel.replace("\\", "/"),
|
||||
"size": src.stat().st_size,
|
||||
"kind": kind,
|
||||
}
|
||||
)
|
||||
_write_manifest(staging, trigger, file_meta)
|
||||
with zipfile.ZipFile(archive_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
|
||||
for fp in sorted(staging.rglob("*")):
|
||||
if fp.is_file():
|
||||
zf.write(fp, fp.relative_to(staging).as_posix())
|
||||
|
||||
size = archive_path.stat().st_size
|
||||
prune_old_backups(root, cfg["retention_days"])
|
||||
state = _load_backup_state()
|
||||
if trigger == "auto":
|
||||
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
||||
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
state["last_backup_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
state["last_backup_file"] = archive_name
|
||||
state["last_trigger"] = trigger
|
||||
_save_backup_state(state)
|
||||
log(f"backup written: {archive_path}")
|
||||
return {
|
||||
"ok": True,
|
||||
"file": archive_name,
|
||||
"path": str(archive_path),
|
||||
"size": size,
|
||||
"file_count": len(file_meta),
|
||||
"trigger": trigger,
|
||||
}
|
||||
|
||||
|
||||
def prune_old_backups(root: Path, retention_days: int) -> int:
|
||||
if not root.is_dir():
|
||||
return 0
|
||||
cutoff = _now_local() - timedelta(days=max(1, retention_days))
|
||||
removed = 0
|
||||
for fp in root.glob("backup_*.zip"):
|
||||
try:
|
||||
mtime = datetime.fromtimestamp(fp.stat().st_mtime, tz=cutoff.tzinfo)
|
||||
except Exception:
|
||||
continue
|
||||
if mtime < cutoff:
|
||||
fp.unlink(missing_ok=True)
|
||||
removed += 1
|
||||
return removed
|
||||
|
||||
|
||||
def list_backups(settings: dict | None = None) -> list[dict[str, Any]]:
|
||||
root = backup_root(settings)
|
||||
rows: list[dict[str, Any]] = []
|
||||
if not root.is_dir():
|
||||
return rows
|
||||
for fp in sorted(root.glob("backup_*.zip"), reverse=True):
|
||||
try:
|
||||
st = fp.stat()
|
||||
except OSError:
|
||||
continue
|
||||
rows.append(
|
||||
{
|
||||
"name": fp.name,
|
||||
"size": st.st_size,
|
||||
"modified_at": datetime.fromtimestamp(st.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
|
||||
def backup_status(settings: dict | None = None) -> dict[str, Any]:
|
||||
cfg = normalize_backup_settings((settings or {}).get("backup") if settings else None)
|
||||
state = _load_backup_state()
|
||||
root = backup_root(settings)
|
||||
return {
|
||||
"ok": True,
|
||||
"settings": cfg,
|
||||
"backup_root": str(root),
|
||||
"state": state,
|
||||
"backups": list_backups(settings)[:50],
|
||||
"timezone": TZ_NAME,
|
||||
}
|
||||
|
||||
|
||||
def _pm2_restart_all() -> dict[str, Any]:
|
||||
if os.name != "posix":
|
||||
return {"ok": False, "skipped": True, "reason": "non-posix"}
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
["pm2", "restart", "all"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
)
|
||||
return {
|
||||
"ok": proc.returncode == 0,
|
||||
"returncode": proc.returncode,
|
||||
"stdout": (proc.stdout or "")[-2000:],
|
||||
"stderr": (proc.stderr or "")[-2000:],
|
||||
}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
|
||||
def restore_backup_archive(
|
||||
archive_path: Path,
|
||||
*,
|
||||
settings: dict | None = None,
|
||||
pre_backup: bool = True,
|
||||
restart_pm2: bool = True,
|
||||
) -> dict[str, Any]:
|
||||
if not archive_path.is_file():
|
||||
return {"ok": False, "error": "备份文件不存在"}
|
||||
|
||||
pre = None
|
||||
if pre_backup:
|
||||
pre = run_backup(trigger="pre_restore", settings=settings)
|
||||
|
||||
restored: list[str] = []
|
||||
skipped: list[str] = []
|
||||
with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp:
|
||||
extract_dir = Path(tmp)
|
||||
with zipfile.ZipFile(archive_path, "r") as zf:
|
||||
zf.extractall(extract_dir)
|
||||
manifest_path = extract_dir / "manifest.json"
|
||||
if not manifest_path.is_file():
|
||||
return {"ok": False, "error": "无效的备份包:缺少 manifest.json"}
|
||||
|
||||
for fp in extract_dir.rglob("*"):
|
||||
if not fp.is_file() or fp.name == "manifest.json":
|
||||
continue
|
||||
rel = fp.relative_to(extract_dir).as_posix()
|
||||
parts = Path(rel).parts
|
||||
if parts[0] == "hub":
|
||||
if len(parts) >= 3 and parts[1] == "data":
|
||||
dest = hub_data_dir() / parts[-1]
|
||||
else:
|
||||
dest = HUB_DIR.joinpath(*parts[1:])
|
||||
else:
|
||||
matched = False
|
||||
for _key, dirname in EXCHANGE_DIRS:
|
||||
if rel.startswith(dirname + "/"):
|
||||
dest = REPO_ROOT / rel
|
||||
matched = True
|
||||
break
|
||||
if not matched:
|
||||
skipped.append(rel)
|
||||
continue
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(fp, dest)
|
||||
restored.append(rel)
|
||||
|
||||
pm2 = _pm2_restart_all() if restart_pm2 else {"ok": False, "skipped": True}
|
||||
state = _load_backup_state()
|
||||
state["last_restore_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
state["last_restore_from"] = archive_path.name
|
||||
_save_backup_state(state)
|
||||
return {
|
||||
"ok": True,
|
||||
"restored": restored,
|
||||
"skipped": skipped,
|
||||
"pre_backup": pre,
|
||||
"pm2": pm2,
|
||||
}
|
||||
|
||||
|
||||
def restore_backup_upload(
|
||||
content: bytes,
|
||||
filename: str,
|
||||
*,
|
||||
settings: dict | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if not content:
|
||||
return {"ok": False, "error": "空文件"}
|
||||
suffix = Path(filename or "").suffix.lower()
|
||||
if suffix != ".zip":
|
||||
return {"ok": False, "error": "仅支持 .zip 备份包"}
|
||||
with tempfile.NamedTemporaryFile(prefix="hub_restore_upload_", suffix=".zip", delete=False) as tf:
|
||||
tf.write(content)
|
||||
temp_path = Path(tf.name)
|
||||
try:
|
||||
return restore_backup_archive(temp_path, settings=settings)
|
||||
finally:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def resolve_backup_download(settings: dict | None, name: str) -> Optional[Path]:
|
||||
if not _safe_archive_name(name):
|
||||
return None
|
||||
fp = backup_root(settings) / name
|
||||
if fp.is_file():
|
||||
return fp
|
||||
return None
|
||||
|
||||
|
||||
def should_run_auto_backup(settings: dict) -> bool:
|
||||
cfg = normalize_backup_settings(settings.get("backup"))
|
||||
if not cfg.get("auto_enabled"):
|
||||
return False
|
||||
now = _now_local()
|
||||
today = now.strftime("%Y-%m-%d")
|
||||
state = _load_backup_state()
|
||||
if state.get("last_auto_day") == today:
|
||||
return False
|
||||
if now.hour < int(cfg.get("auto_hour", 0)):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def mark_auto_backup_done() -> None:
|
||||
state = _load_backup_state()
|
||||
state["last_auto_day"] = _now_local().strftime("%Y-%m-%d")
|
||||
state["last_auto_at"] = _now_local().strftime("%Y-%m-%d %H:%M:%S")
|
||||
_save_backup_state(state)
|
||||
@@ -26,9 +26,9 @@ HUB_TRUST_LAN=true
|
||||
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"})
|
||||
# HUB_ALLOW_PUBLIC=true
|
||||
|
||||
# 中控 Web 登录(密码非空即启用;反代到公网时务必设置用户名+密码)
|
||||
# HUB_USERNAME=admin
|
||||
# HUB_PASSWORD=your-strong-password-here
|
||||
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
|
||||
HUB_USERNAME=admin
|
||||
HUB_PASSWORD=admin123
|
||||
# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接)
|
||||
# HUB_SESSION_SECRET=another-long-random-string
|
||||
# HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookie;http://内网IP:5100 仍可登录
|
||||
@@ -106,6 +106,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||
TRADING_DAY_RESET_HOUR=8
|
||||
# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180)
|
||||
# HUB_FUND_HISTORY_DAYS=180
|
||||
# 资金概况:曲线与回撤统计起始交易日(页面说明与曲线均读取此项;该日之前不记、不展示)
|
||||
# 修改后须重启 manual-trading-hub(pm2 restart manual-trading-hub)
|
||||
# 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT)
|
||||
# HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal
|
||||
# 资金概况:曲线与回撤统计起始交易日
|
||||
HUB_FUND_HISTORY_START_DAY=2026-06-09
|
||||
|
||||
+125
-2
@@ -86,7 +86,7 @@ from env_load import load_hub_dotenv
|
||||
load_hub_dotenv()
|
||||
|
||||
import httpx
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -99,6 +99,15 @@ from settings_store import (
|
||||
normalize_supervisor_settings,
|
||||
save_settings,
|
||||
)
|
||||
from lib.hub.hub_backup_lib import (
|
||||
backup_status,
|
||||
normalize_backup_settings,
|
||||
resolve_backup_download,
|
||||
restore_backup_archive,
|
||||
restore_backup_upload,
|
||||
run_backup,
|
||||
should_run_auto_backup,
|
||||
)
|
||||
from hub_web_auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE_SEC,
|
||||
@@ -157,6 +166,8 @@ _last_archive_sync: dict | None = None
|
||||
_volume_rank_stop: asyncio.Event | None = None
|
||||
_volume_rank_task: asyncio.Task | None = None
|
||||
_volume_rank_cache: dict | None = None
|
||||
_backup_stop: asyncio.Event | None = None
|
||||
_backup_task: asyncio.Task | None = None
|
||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
|
||||
@@ -518,9 +529,28 @@ async def _run_supervisor_tick() -> dict:
|
||||
)
|
||||
|
||||
|
||||
async def _backup_scheduler_loop() -> None:
|
||||
global _backup_stop
|
||||
stop = _backup_stop
|
||||
if stop is None:
|
||||
return
|
||||
while not stop.is_set():
|
||||
try:
|
||||
settings = load_settings()
|
||||
if should_run_auto_backup(settings):
|
||||
await asyncio.to_thread(run_backup, trigger="auto", settings=settings)
|
||||
except Exception as e:
|
||||
print(f"[backup] auto backup failed: {e}", flush=True)
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _hub_lifespan(_app: FastAPI):
|
||||
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
||||
global _backup_stop, _backup_task
|
||||
set_supervisor_notify_hook(supervisor_store.bump)
|
||||
await board_store.start(_run_board_aggregate)
|
||||
await dashboard_store.start(_run_dashboard_aggregate)
|
||||
@@ -530,9 +560,21 @@ async def _hub_lifespan(_app: FastAPI):
|
||||
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
|
||||
_volume_rank_stop = asyncio.Event()
|
||||
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank")
|
||||
_backup_stop = asyncio.Event()
|
||||
_backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _backup_stop:
|
||||
_backup_stop.set()
|
||||
if _backup_task:
|
||||
_backup_task.cancel()
|
||||
try:
|
||||
await _backup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_backup_task = None
|
||||
_backup_stop = None
|
||||
if _archive_sync_stop:
|
||||
_archive_sync_stop.set()
|
||||
if _archive_sync_task:
|
||||
@@ -879,10 +921,20 @@ class SupervisorSettingsBody(BaseModel):
|
||||
reopen_after_close_minutes: int = 30
|
||||
|
||||
|
||||
class BackupSettingsBody(BaseModel):
|
||||
auto_enabled: bool = True
|
||||
auto_hour: int = Field(default=0, ge=0, le=23)
|
||||
retention_days: int = Field(default=30, ge=1, le=365)
|
||||
include_env: bool = True
|
||||
include_exchange_images: bool = False
|
||||
backup_root: str = ""
|
||||
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
exchanges: list[dict] = Field(default_factory=list)
|
||||
display: SettingsDisplayBody | None = None
|
||||
supervisor: SupervisorSettingsBody | None = None
|
||||
backup: BackupSettingsBody | None = None
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
@@ -903,7 +955,18 @@ def api_save_settings(body: SettingsBody):
|
||||
supervisor = normalize_supervisor_settings(existing.get("supervisor"))
|
||||
if body.supervisor is not None:
|
||||
supervisor = normalize_supervisor_settings(body.supervisor.model_dump())
|
||||
save_settings({"version": 1, "exchanges": to_save, "display": display, "supervisor": supervisor})
|
||||
backup = normalize_backup_settings(existing.get("backup"))
|
||||
if body.backup is not None:
|
||||
backup = normalize_backup_settings(body.backup.model_dump())
|
||||
save_settings(
|
||||
{
|
||||
"version": 1,
|
||||
"exchanges": to_save,
|
||||
"display": display,
|
||||
"supervisor": supervisor,
|
||||
"backup": backup,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "settings": load_settings()}
|
||||
|
||||
|
||||
@@ -1290,6 +1353,65 @@ async def api_chart_poll_meta():
|
||||
return chart_poll_store.event_dict()
|
||||
|
||||
|
||||
@app.get("/api/backup/status")
|
||||
def api_backup_status():
|
||||
return backup_status(load_settings())
|
||||
|
||||
|
||||
@app.post("/api/backup/run")
|
||||
async def api_backup_run():
|
||||
result = await asyncio.to_thread(run_backup, trigger="manual", settings=load_settings())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "backup failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/backup/download/{name}")
|
||||
def api_backup_download(name: str):
|
||||
fp = resolve_backup_download(load_settings(), name)
|
||||
if not fp:
|
||||
raise HTTPException(status_code=404, detail="backup not found")
|
||||
return FileResponse(
|
||||
str(fp),
|
||||
media_type="application/zip",
|
||||
filename=fp.name,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/backup/restore")
|
||||
async def api_backup_restore(
|
||||
file: UploadFile = File(...),
|
||||
confirm: str = Form(""),
|
||||
):
|
||||
if (confirm or "").strip().upper() != "RESTORE":
|
||||
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
|
||||
content = await file.read()
|
||||
result = await asyncio.to_thread(
|
||||
restore_backup_upload,
|
||||
content,
|
||||
file.filename or "backup.zip",
|
||||
settings=load_settings(),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/backup/restore-local")
|
||||
async def api_backup_restore_local(body: dict = Body(...)):
|
||||
confirm = str(body.get("confirm") or "").strip().upper()
|
||||
name = str(body.get("name") or "").strip()
|
||||
if confirm != "RESTORE":
|
||||
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
|
||||
fp = resolve_backup_download(load_settings(), name)
|
||||
if not fp:
|
||||
raise HTTPException(status_code=404, detail="backup not found")
|
||||
result = await asyncio.to_thread(restore_backup_archive, fp, settings=load_settings())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/settings/meta")
|
||||
def api_settings_meta():
|
||||
po = public_origin()
|
||||
@@ -1304,6 +1426,7 @@ def api_settings_meta():
|
||||
else "复盘/展示链接已替换为对外地址"
|
||||
),
|
||||
"password_required": password_required(),
|
||||
"default_username": expected_username(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from secrets import compare_digest
|
||||
SESSION_COOKIE = "hub_sess"
|
||||
SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400)
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin123"
|
||||
|
||||
|
||||
def _env_username() -> str:
|
||||
@@ -20,12 +21,13 @@ def _env_username() -> str:
|
||||
|
||||
|
||||
def _env_password() -> str:
|
||||
return (os.getenv("HUB_PASSWORD") or "").strip()
|
||||
raw = (os.getenv("HUB_PASSWORD") or "").strip()
|
||||
return raw or DEFAULT_PASSWORD
|
||||
|
||||
|
||||
def password_required() -> bool:
|
||||
"""已配置密码即要求登录(用户名未设时默认 admin)。"""
|
||||
return bool(_env_password())
|
||||
"""默认启用登录(admin / admin123,可通过 .env 覆盖)。"""
|
||||
return True
|
||||
|
||||
|
||||
def expected_username() -> str:
|
||||
@@ -33,8 +35,6 @@ def expected_username() -> str:
|
||||
|
||||
|
||||
def verify_credentials(username: str, password: str) -> bool:
|
||||
if not _env_password():
|
||||
return True
|
||||
u_ok = compare_digest(expected_username(), (username or "").strip())
|
||||
p_ok = compare_digest(_env_password(), (password or "").strip())
|
||||
return u_ok and p_ok
|
||||
|
||||
@@ -8,13 +8,17 @@ from pathlib import Path
|
||||
|
||||
DIR = Path(__file__).resolve().parent
|
||||
SETTINGS_PATH = DIR / "hub_settings.json"
|
||||
_REPO_ROOT = DIR.parent
|
||||
|
||||
import sys
|
||||
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
if str(DIR) not in sys.path:
|
||||
sys.path.insert(0, str(DIR))
|
||||
|
||||
from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings
|
||||
from lib.hub.hub_backup_lib import normalize_backup_settings
|
||||
|
||||
DEFAULT_DISPLAY = {
|
||||
"show_account_pnl": True,
|
||||
@@ -106,6 +110,7 @@ def load_settings() -> dict:
|
||||
pass
|
||||
data["display"] = normalize_display_prefs(data.get("display"))
|
||||
data["supervisor"] = normalize_supervisor_settings(data.get("supervisor"))
|
||||
data["backup"] = normalize_backup_settings(data.get("backup"))
|
||||
force_off = env_force_disabled_ids()
|
||||
for ex in data.get("exchanges") or []:
|
||||
if str(ex.get("id")) in force_off:
|
||||
@@ -120,6 +125,7 @@ def save_settings(data: dict) -> None:
|
||||
payload = dict(data)
|
||||
payload["display"] = normalize_display_prefs(payload.get("display"))
|
||||
payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor"))
|
||||
payload["backup"] = normalize_backup_settings(payload.get("backup"))
|
||||
SETTINGS_PATH.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -2904,6 +2904,88 @@ button.btn-sm {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.backup-settings-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.backup-status-line {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.backup-status-line.err {
|
||||
color: var(--danger, #f87171);
|
||||
}
|
||||
|
||||
.backup-restore-upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.backup-upload-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.backup-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.backup-meta code {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.backup-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.backup-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.backup-table th,
|
||||
.backup-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-row-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backup-row-actions .ghost,
|
||||
.backup-row-actions .danger {
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -3969,6 +3969,7 @@
|
||||
syncSupervisorSettingsUI(data);
|
||||
renderSettingsList(data);
|
||||
initSettingsSectionFolds();
|
||||
if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 系统设置 · 备份与恢复
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-settings");
|
||||
if (!page) return;
|
||||
|
||||
const elAuto = document.getElementById("backup-auto-enabled");
|
||||
const elHour = document.getElementById("backup-auto-hour");
|
||||
const elRetention = document.getElementById("backup-retention-days");
|
||||
const elIncludeEnv = document.getElementById("backup-include-env");
|
||||
const elIncludeImages = document.getElementById("backup-include-images");
|
||||
const elRoot = document.getElementById("backup-root");
|
||||
const elStatus = document.getElementById("backup-status-line");
|
||||
const elList = document.getElementById("backup-list");
|
||||
const elRun = document.getElementById("backup-run-now");
|
||||
const elRestoreFile = document.getElementById("backup-restore-file");
|
||||
const elRestoreBtn = document.getElementById("backup-restore-upload-btn");
|
||||
|
||||
let settingsCache = null;
|
||||
let statusCache = null;
|
||||
|
||||
function fmtBytes(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v) || v < 0) return "—";
|
||||
if (v < 1024) return v + " B";
|
||||
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
|
||||
return (v / (1024 * 1024)).toFixed(2) + " MB";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
if (!elStatus) return;
|
||||
elStatus.textContent = msg || "";
|
||||
elStatus.className = "backup-status-line" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function collectBackupFromUI() {
|
||||
return {
|
||||
auto_enabled: !!(elAuto && elAuto.checked),
|
||||
auto_hour: Math.max(0, Math.min(23, parseInt(elHour && elHour.value, 10) || 0)),
|
||||
retention_days: Math.max(1, Math.min(365, parseInt(elRetention && elRetention.value, 10) || 30)),
|
||||
include_env: !!(elIncludeEnv && elIncludeEnv.checked),
|
||||
include_exchange_images: !!(elIncludeImages && elIncludeImages.checked),
|
||||
backup_root: (elRoot && elRoot.value || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function syncBackupUI(data) {
|
||||
const b = (data && data.backup) || {};
|
||||
if (elAuto) elAuto.checked = b.auto_enabled !== false;
|
||||
if (elHour) elHour.value = b.auto_hour != null ? b.auto_hour : 0;
|
||||
if (elRetention) elRetention.value = b.retention_days != null ? b.retention_days : 30;
|
||||
if (elIncludeEnv) elIncludeEnv.checked = b.include_env !== false;
|
||||
if (elIncludeImages) elIncludeImages.checked = !!b.include_exchange_images;
|
||||
if (elRoot) elRoot.value = b.backup_root || "";
|
||||
}
|
||||
|
||||
function renderBackupList(status) {
|
||||
if (!elList) return;
|
||||
const rows = (status && status.backups) || [];
|
||||
const state = (status && status.state) || {};
|
||||
const root = (status && status.backup_root) || "";
|
||||
let html = '<div class="backup-meta">';
|
||||
html += '<div>目录:<code>' + esc(root) + '</code></div>';
|
||||
if (state.last_backup_at) {
|
||||
html += '<div>上次备份:' + esc(state.last_backup_at) + '(' + esc(state.last_trigger || "") + ")</div>";
|
||||
}
|
||||
if (state.last_auto_at) {
|
||||
html += '<div>上次自动:' + esc(state.last_auto_at) + "</div>";
|
||||
}
|
||||
if (state.last_restore_at) {
|
||||
html += '<div>上次恢复:' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "</div>";
|
||||
}
|
||||
html += "</div>";
|
||||
if (!rows.length) {
|
||||
html += '<p class="backup-empty">暂无备份文件</p>';
|
||||
elList.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
html += '<table class="backup-table"><thead><tr><th>文件</th><th>大小</th><th>时间</th><th></th></tr></thead><tbody>';
|
||||
rows.forEach(function (row) {
|
||||
html +=
|
||||
"<tr><td>" +
|
||||
esc(row.name) +
|
||||
"</td><td>" +
|
||||
fmtBytes(row.size) +
|
||||
"</td><td>" +
|
||||
esc(row.modified_at || "") +
|
||||
'</td><td class="backup-row-actions">' +
|
||||
'<a class="ghost" href="/api/backup/download/' +
|
||||
encodeURIComponent(row.name) +
|
||||
'" download>下载</a> ' +
|
||||
'<button type="button" class="danger backup-restore-local" data-name="' +
|
||||
escAttr(row.name) +
|
||||
'">恢复</button></td></tr>';
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
elList.innerHTML = html;
|
||||
elList.querySelectorAll(".backup-restore-local").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
restoreLocal(btn.getAttribute("data-name"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function loadSettingsData() {
|
||||
const r = await fetch("/api/settings", { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error("加载设置失败");
|
||||
settingsCache = await r.json();
|
||||
syncBackupUI(settingsCache);
|
||||
}
|
||||
|
||||
async function loadBackupStatus() {
|
||||
const r = await fetch("/api/backup/status", { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error("加载备份状态失败");
|
||||
statusCache = await r.json();
|
||||
renderBackupList(statusCache);
|
||||
}
|
||||
|
||||
async function saveBackupSettings() {
|
||||
if (!settingsCache) await loadSettingsData();
|
||||
const body = {
|
||||
exchanges: settingsCache.exchanges || [],
|
||||
display: settingsCache.display,
|
||||
supervisor: settingsCache.supervisor,
|
||||
backup: collectBackupFromUI(),
|
||||
};
|
||||
const r = await fetch("/api/settings", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error("保存失败");
|
||||
settingsCache = (await r.json()).settings || settingsCache;
|
||||
syncBackupUI(settingsCache);
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("备份设置已保存");
|
||||
}
|
||||
|
||||
async function runBackupNow() {
|
||||
setStatus("备份中…");
|
||||
const r = await fetch("/api/backup/run", { method: "POST", credentials: "same-origin" });
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "备份失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("完成:" + (data.file || "") + "(" + fmtBytes(data.size) + ")");
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("备份完成");
|
||||
}
|
||||
|
||||
async function restoreLocal(name) {
|
||||
if (!name) return;
|
||||
if (!window.confirm("确认从服务器备份 " + name + " 恢复?\n恢复前会自动做 pre-restore 快照并重启 PM2。")) return;
|
||||
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
|
||||
setStatus("恢复中…");
|
||||
const r = await fetch("/api/backup/restore-local", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name, confirm: "RESTORE" }),
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "恢复失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
|
||||
}
|
||||
|
||||
async function restoreUpload() {
|
||||
const file = elRestoreFile && elRestoreFile.files && elRestoreFile.files[0];
|
||||
if (!file) {
|
||||
setStatus("请选择 .zip 备份文件", true);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("确认上传并恢复 " + file.name + "?\n恢复前会自动做 pre-restore 快照并重启 PM2。")) return;
|
||||
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
|
||||
setStatus("上传并恢复中…");
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("confirm", "RESTORE");
|
||||
const r = await fetch("/api/backup/restore", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: fd,
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "恢复失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
|
||||
if (elRestoreFile) elRestoreFile.value = "";
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
|
||||
}
|
||||
|
||||
window.initBackupSettingsUI = async function () {
|
||||
try {
|
||||
await loadSettingsData();
|
||||
await loadBackupStatus();
|
||||
setStatus("");
|
||||
} catch (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
}
|
||||
};
|
||||
|
||||
if (elRun) elRun.addEventListener("click", function () {
|
||||
runBackupNow().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
|
||||
if (elRestoreBtn) elRestoreBtn.addEventListener("click", function () {
|
||||
restoreUpload().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
|
||||
page.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".settings-section-save[data-settings-section='backup']");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
saveBackupSettings().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -862,7 +862,7 @@
|
||||
<div class="hint-body">
|
||||
保存后写入 <code>hub_settings.json</code>。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。<br />
|
||||
<code>HUB_DISABLED_IDS</code> 可强制关闭账户;<code>HUB_BRIDGE_TOKEN</code> 与实例一致,或实例 <code>APP_AUTH_DISABLED=true</code>。<br />
|
||||
公网反代请在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code> 与 <code>HUB_PASSWORD</code>;HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code>。
|
||||
公网反代请在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code> 与 <code>HUB_PASSWORD</code>(默认 <code>admin</code> / <code>admin123</code>);HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code>。
|
||||
</div>
|
||||
</details>
|
||||
<p id="settings-meta-line" class="settings-meta-line"></p>
|
||||
@@ -989,6 +989,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section card settings-backup-panel" data-settings-section="backup">
|
||||
<div class="settings-section-head">
|
||||
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
|
||||
<h3 class="settings-display-title">备份与恢复</h3>
|
||||
<button type="button" class="primary settings-section-save" data-settings-section="backup">保存</button>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<p class="settings-display-hint">
|
||||
打包四所 <code>crypto.db</code>、中控 K 线/归档等 SQLite、<code>hub_settings.json</code> 与 <code>.env</code>(可选)。
|
||||
恢复前会自动做一次 pre-restore 快照,并尝试 <code>pm2 restart all</code>。
|
||||
</p>
|
||||
<div class="settings-grid backup-settings-grid">
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="backup-auto-enabled" checked />
|
||||
每日自动备份(北京时间)
|
||||
</label>
|
||||
<div class="field">
|
||||
<label>自动备份时刻(时,0–23)</label>
|
||||
<input id="backup-auto-hour" type="number" min="0" max="23" step="1" value="0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>保留天数</label>
|
||||
<input id="backup-retention-days" type="number" min="1" max="365" step="1" value="30" />
|
||||
</div>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="backup-include-env" checked />
|
||||
包含 .env 配置文件
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="backup-include-images" />
|
||||
包含四所 static/images 截图
|
||||
</label>
|
||||
<div class="field field-wide">
|
||||
<label>备份目录(留空默认 /root/backups/crypto_monitor_portal)</label>
|
||||
<input id="backup-root" type="text" placeholder="/root/backups/crypto_monitor_portal" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button type="button" id="backup-run-now" class="primary">立即备份</button>
|
||||
<span id="backup-status-line" class="backup-status-line"></span>
|
||||
</div>
|
||||
<div class="backup-restore-upload">
|
||||
<label class="backup-upload-label">
|
||||
<span>上传备份包恢复(.zip)</span>
|
||||
<input id="backup-restore-file" type="file" accept=".zip,application/zip" />
|
||||
</label>
|
||||
<button type="button" id="backup-restore-upload-btn" class="danger">上传并恢复</button>
|
||||
</div>
|
||||
<div id="backup-list" class="backup-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section card" data-settings-section="exchanges">
|
||||
<div class="settings-section-head">
|
||||
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
|
||||
@@ -1064,6 +1115,7 @@
|
||||
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
<script src="/assets/time_close_ui.js?v=2"></script>
|
||||
<script src="/assets/backup.js?v=1"></script>
|
||||
<script src="/assets/app.js?v=20260614-instance-nav"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<p id="login-err" class="login-err" hidden></p>
|
||||
<p id="login-hint" class="login-foot" hidden></p>
|
||||
</form>
|
||||
<p class="login-foot">账号在云端 hub 的 <code>.env</code>:<code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
|
||||
<p class="login-foot">默认账号 <code>admin</code> / <code>admin123</code>;可在 hub <code>.env</code> 修改 <code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
"""hub_backup_lib 单元测试。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
from lib.hub import hub_backup_lib as backup
|
||||
|
||||
|
||||
class HubBackupLibTest(unittest.TestCase):
|
||||
def test_normalize_backup_settings(self):
|
||||
cfg = backup.normalize_backup_settings({"auto_hour": 99, "retention_days": 0})
|
||||
self.assertEqual(cfg["auto_hour"], 23)
|
||||
self.assertEqual(cfg["retention_days"], 1)
|
||||
|
||||
def test_safe_archive_name(self):
|
||||
self.assertTrue(backup._safe_archive_name("backup_2026-07-02_163045.zip"))
|
||||
self.assertFalse(backup._safe_archive_name("../evil.zip"))
|
||||
|
||||
def test_run_and_restore_roundtrip(self):
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
root = Path(tmp) / "portal"
|
||||
root.mkdir(parents=True)
|
||||
settings = {
|
||||
"backup": {
|
||||
"auto_enabled": False,
|
||||
"backup_root": str(root),
|
||||
"include_env": False,
|
||||
"include_exchange_images": False,
|
||||
}
|
||||
}
|
||||
hub_settings = backup.HUB_DIR / "hub_settings.json"
|
||||
had = hub_settings.is_file()
|
||||
old = hub_settings.read_text(encoding="utf-8") if had else None
|
||||
try:
|
||||
if not had:
|
||||
hub_settings.write_text('{"version":1,"exchanges":[]}', encoding="utf-8")
|
||||
result = backup.run_backup(trigger="manual", settings=settings)
|
||||
self.assertTrue(result.get("ok"), result)
|
||||
archive = Path(result["path"])
|
||||
self.assertTrue(archive.is_file())
|
||||
with zipfile.ZipFile(archive, "r") as zf:
|
||||
names = zf.namelist()
|
||||
self.assertIn("manifest.json", names)
|
||||
manifest = json.loads(
|
||||
zipfile.ZipFile(archive, "r").read("manifest.json").decode("utf-8")
|
||||
)
|
||||
self.assertEqual(manifest.get("trigger"), "manual")
|
||||
finally:
|
||||
if had and old is not None:
|
||||
hub_settings.write_text(old, encoding="utf-8")
|
||||
elif not had and hub_settings.is_file():
|
||||
hub_settings.unlink(missing_ok=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user