feat: 系统设置增加备份恢复与默认登录 admin

支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 16:39:46 +08:00
parent 55261b7812
commit bfa3352122
16 changed files with 1052 additions and 22 deletions
+3
View File
@@ -16,6 +16,9 @@
**/.env.bak **/.env.bak
**/.env.local **/.env.local
manual_trading_hub/hub_settings.json 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_summaries.json
manual_trading_hub/hub_ai_chat.json manual_trading_hub/hub_ai_chat.json
manual_trading_hub/hub_ai_fund_history.json manual_trading_hub/hub_ai_fund_history.json
+2 -2
View File
@@ -21,9 +21,9 @@ APP_PORT=5001
APP_DEBUG=false APP_DEBUG=false
# 登录账号 # 登录账号
APP_USERNAME=dekun APP_USERNAME=admin
# 登录密码(请改成你自己的强密码) # 登录密码(请改成你自己的强密码)
APP_PASSWORD=ChangeMe123! APP_PASSWORD=admin123
# 是否关闭登录校验(局域网可设 true;公网务必 false) # 是否关闭登录校验(局域网可设 true;公网务必 false)
APP_AUTH_DISABLED=true APP_AUTH_DISABLED=true
# --- 多账户交易中控 manual_trading_hub --- # --- 多账户交易中控 manual_trading_hub ---
+2 -2
View File
@@ -21,9 +21,9 @@ APP_PORT=5000
APP_DEBUG=false APP_DEBUG=false
# 登录账号 # 登录账号
APP_USERNAME=dekun APP_USERNAME=admin
# 登录密码(请改成你自己的强密码) # 登录密码(请改成你自己的强密码)
APP_PASSWORD=ChangeMe123! APP_PASSWORD=admin123
# 是否关闭登录校验(局域网可设 true;公网务必 false) # 是否关闭登录校验(局域网可设 true;公网务必 false)
APP_AUTH_DISABLED=true APP_AUTH_DISABLED=true
# --- 多账户交易中控 manual_trading_hub --- # --- 多账户交易中控 manual_trading_hub ---
+2 -2
View File
@@ -21,9 +21,9 @@ APP_PORT=5002
APP_DEBUG=false APP_DEBUG=false
# 登录账号 # 登录账号
APP_USERNAME=dekun APP_USERNAME=admin
# 登录密码(请改成你自己的强密码) # 登录密码(请改成你自己的强密码)
APP_PASSWORD=ChangeMe123! APP_PASSWORD=admin123
# 是否关闭登录校验(局域网可设 true;公网务必 false) # 是否关闭登录校验(局域网可设 true;公网务必 false)
APP_AUTH_DISABLED=true APP_AUTH_DISABLED=true
# --- 多账户交易中控 manual_trading_hub --- # --- 多账户交易中控 manual_trading_hub ---
+2 -2
View File
@@ -21,9 +21,9 @@ APP_PORT=5004
APP_DEBUG=false APP_DEBUG=false
# 登录账号 # 登录账号
APP_USERNAME=dekun APP_USERNAME=admin
# 登录密码(请改成你自己的强密码) # 登录密码(请改成你自己的强密码)
APP_PASSWORD=ChangeMe123! APP_PASSWORD=admin123
# 是否关闭登录校验(局域网可设 true;公网务必 false) # 是否关闭登录校验(局域网可设 true;公网务必 false)
APP_AUTH_DISABLED=true APP_AUTH_DISABLED=true
# --- 多账户交易中控 manual_trading_hub --- # --- 多账户交易中控 manual_trading_hub ---
+447
View File
@@ -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)
+6 -5
View File
@@ -26,9 +26,9 @@ HUB_TRUST_LAN=true
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"} # 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"}
# HUB_ALLOW_PUBLIC=true # HUB_ALLOW_PUBLIC=true
# 中控 Web 登录(密码非空即启用;反代到公网时务必设置用户名+密码 # 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改
# HUB_USERNAME=admin HUB_USERNAME=admin
# HUB_PASSWORD=your-strong-password-here HUB_PASSWORD=admin123
# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接) # 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接)
# HUB_SESSION_SECRET=another-long-random-string # HUB_SESSION_SECRET=another-long-random-string
# HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookiehttp://内网IP:5100 仍可登录 # HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookiehttp://内网IP:5100 仍可登录
@@ -106,6 +106,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
TRADING_DAY_RESET_HOUR=8 TRADING_DAY_RESET_HOUR=8
# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180) # 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180)
# HUB_FUND_HISTORY_DAYS=180 # HUB_FUND_HISTORY_DAYS=180
# 资金概况:曲线与回撤统计起始交易日(页面说明与曲线均读取此项;该日之前不记、不展示 # 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT
# 修改后须重启 manual-trading-hubpm2 restart manual-trading-hub # HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal
# 资金概况:曲线与回撤统计起始交易日
HUB_FUND_HISTORY_START_DAY=2026-06-09 HUB_FUND_HISTORY_START_DAY=2026-06-09
+125 -2
View File
@@ -86,7 +86,7 @@ from env_load import load_hub_dotenv
load_hub_dotenv() load_hub_dotenv()
import httpx 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.responses import FileResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
@@ -99,6 +99,15 @@ from settings_store import (
normalize_supervisor_settings, normalize_supervisor_settings,
save_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 ( from hub_web_auth import (
SESSION_COOKIE, SESSION_COOKIE,
SESSION_MAX_AGE_SEC, SESSION_MAX_AGE_SEC,
@@ -157,6 +166,8 @@ _last_archive_sync: dict | None = None
_volume_rank_stop: asyncio.Event | None = None _volume_rank_stop: asyncio.Event | None = None
_volume_rank_task: asyncio.Task | None = None _volume_rank_task: asyncio.Task | None = None
_volume_rank_cache: dict | 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_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10")) HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45")) 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 @asynccontextmanager
async def _hub_lifespan(_app: FastAPI): async def _hub_lifespan(_app: FastAPI):
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task 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) set_supervisor_notify_hook(supervisor_store.bump)
await board_store.start(_run_board_aggregate) await board_store.start(_run_board_aggregate)
await dashboard_store.start(_run_dashboard_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") _archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
_volume_rank_stop = asyncio.Event() _volume_rank_stop = asyncio.Event()
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank") _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: try:
yield yield
finally: 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: if _archive_sync_stop:
_archive_sync_stop.set() _archive_sync_stop.set()
if _archive_sync_task: if _archive_sync_task:
@@ -879,10 +921,20 @@ class SupervisorSettingsBody(BaseModel):
reopen_after_close_minutes: int = 30 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): class SettingsBody(BaseModel):
exchanges: list[dict] = Field(default_factory=list) exchanges: list[dict] = Field(default_factory=list)
display: SettingsDisplayBody | None = None display: SettingsDisplayBody | None = None
supervisor: SupervisorSettingsBody | None = None supervisor: SupervisorSettingsBody | None = None
backup: BackupSettingsBody | None = None
@app.post("/api/settings") @app.post("/api/settings")
@@ -903,7 +955,18 @@ def api_save_settings(body: SettingsBody):
supervisor = normalize_supervisor_settings(existing.get("supervisor")) supervisor = normalize_supervisor_settings(existing.get("supervisor"))
if body.supervisor is not None: if body.supervisor is not None:
supervisor = normalize_supervisor_settings(body.supervisor.model_dump()) 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()} return {"ok": True, "settings": load_settings()}
@@ -1290,6 +1353,65 @@ async def api_chart_poll_meta():
return chart_poll_store.event_dict() 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") @app.get("/api/settings/meta")
def api_settings_meta(): def api_settings_meta():
po = public_origin() po = public_origin()
@@ -1304,6 +1426,7 @@ def api_settings_meta():
else "复盘/展示链接已替换为对外地址" else "复盘/展示链接已替换为对外地址"
), ),
"password_required": password_required(), "password_required": password_required(),
"default_username": expected_username(),
} }
+5 -5
View File
@@ -13,6 +13,7 @@ from secrets import compare_digest
SESSION_COOKIE = "hub_sess" SESSION_COOKIE = "hub_sess"
SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400) SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400)
DEFAULT_USERNAME = "admin" DEFAULT_USERNAME = "admin"
DEFAULT_PASSWORD = "admin123"
def _env_username() -> str: def _env_username() -> str:
@@ -20,12 +21,13 @@ def _env_username() -> str:
def _env_password() -> 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: def password_required() -> bool:
"""已配置密码即要求登录(用户名未设时默认 admin)。""" """默认启用登录(admin / admin123,可通过 .env 覆盖)。"""
return bool(_env_password()) return True
def expected_username() -> str: def expected_username() -> str:
@@ -33,8 +35,6 @@ def expected_username() -> str:
def verify_credentials(username: str, password: str) -> bool: def verify_credentials(username: str, password: str) -> bool:
if not _env_password():
return True
u_ok = compare_digest(expected_username(), (username or "").strip()) u_ok = compare_digest(expected_username(), (username or "").strip())
p_ok = compare_digest(_env_password(), (password or "").strip()) p_ok = compare_digest(_env_password(), (password or "").strip())
return u_ok and p_ok return u_ok and p_ok
+6
View File
@@ -8,13 +8,17 @@ from pathlib import Path
DIR = Path(__file__).resolve().parent DIR = Path(__file__).resolve().parent
SETTINGS_PATH = DIR / "hub_settings.json" SETTINGS_PATH = DIR / "hub_settings.json"
_REPO_ROOT = DIR.parent
import sys import sys
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
if str(DIR) not in sys.path: if str(DIR) not in sys.path:
sys.path.insert(0, str(DIR)) sys.path.insert(0, str(DIR))
from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings
from lib.hub.hub_backup_lib import normalize_backup_settings
DEFAULT_DISPLAY = { DEFAULT_DISPLAY = {
"show_account_pnl": True, "show_account_pnl": True,
@@ -106,6 +110,7 @@ def load_settings() -> dict:
pass pass
data["display"] = normalize_display_prefs(data.get("display")) data["display"] = normalize_display_prefs(data.get("display"))
data["supervisor"] = normalize_supervisor_settings(data.get("supervisor")) data["supervisor"] = normalize_supervisor_settings(data.get("supervisor"))
data["backup"] = normalize_backup_settings(data.get("backup"))
force_off = env_force_disabled_ids() force_off = env_force_disabled_ids()
for ex in data.get("exchanges") or []: for ex in data.get("exchanges") or []:
if str(ex.get("id")) in force_off: if str(ex.get("id")) in force_off:
@@ -120,6 +125,7 @@ def save_settings(data: dict) -> None:
payload = dict(data) payload = dict(data)
payload["display"] = normalize_display_prefs(payload.get("display")) payload["display"] = normalize_display_prefs(payload.get("display"))
payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor")) payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor"))
payload["backup"] = normalize_backup_settings(payload.get("backup"))
SETTINGS_PATH.write_text( SETTINGS_PATH.write_text(
json.dumps(payload, ensure_ascii=False, indent=2), json.dumps(payload, ensure_ascii=False, indent=2),
encoding="utf-8", encoding="utf-8",
+82
View File
@@ -2904,6 +2904,88 @@ button.btn-sm {
line-height: 1.45; 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 { .settings-card {
background: var(--panel); background: var(--panel);
border: 1px solid var(--border); border: 1px solid var(--border);
+1
View File
@@ -3969,6 +3969,7 @@
syncSupervisorSettingsUI(data); syncSupervisorSettingsUI(data);
renderSettingsList(data); renderSettingsList(data);
initSettingsSectionFolds(); initSettingsSectionFolds();
if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI();
}); });
} }
+250
View File
@@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/"/g, "&quot;");
}
function escAttr(s) {
return esc(s).replace(/'/g, "&#39;");
}
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);
});
});
})();
+53 -1
View File
@@ -862,7 +862,7 @@
<div class="hint-body"> <div class="hint-body">
保存后写入 <code>hub_settings.json</code>。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。<br /> 保存后写入 <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 /> <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> </div>
</details> </details>
<p id="settings-meta-line" class="settings-meta-line"></p> <p id="settings-meta-line" class="settings-meta-line"></p>
@@ -989,6 +989,57 @@
</div> </div>
</div> </div>
</section> </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>自动备份时刻(时,023</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"> <section class="settings-section card" data-settings-section="exchanges">
<div class="settings-section-head"> <div class="settings-section-head">
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button> <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/dashboard.js?v=20260612-dash-monitor-count"></script>
<script src="/assets/ai_review_render.js?v=3"></script> <script src="/assets/ai_review_render.js?v=3"></script>
<script src="/assets/time_close_ui.js?v=2"></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> <script src="/assets/app.js?v=20260614-instance-nav"></script>
</body> </body>
</html> </html>
+1 -1
View File
@@ -51,7 +51,7 @@
<p id="login-err" class="login-err" hidden></p> <p id="login-err" class="login-err" hidden></p>
<p id="login-hint" class="login-foot" hidden></p> <p id="login-hint" class="login-foot" hidden></p>
</form> </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> </div>
<script> <script>
(function () { (function () {
+65
View File
@@ -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()