Add frontend backup upload and list-based restore with validation.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -92,6 +92,7 @@ app = Flask(
|
|||||||
static_folder=os.path.join(ROOT, "modules", "web", "static"),
|
static_folder=os.path.join(ROOT, "modules", "web", "static"),
|
||||||
)
|
)
|
||||||
app.secret_key = os.getenv("SECRET_KEY", "futures_monitor_default_secret")
|
app.secret_key = os.getenv("SECRET_KEY", "futures_monitor_default_secret")
|
||||||
|
app.config["MAX_CONTENT_LENGTH"] = int(os.getenv("MAX_BACKUP_UPLOAD_MB", "500")) * 1024 * 1024
|
||||||
|
|
||||||
HOST = os.getenv("HOST", "0.0.0.0")
|
HOST = os.getenv("HOST", "0.0.0.0")
|
||||||
PORT = int(os.getenv("PORT", "6600"))
|
PORT = int(os.getenv("PORT", "6600"))
|
||||||
|
|||||||
+429
-4
@@ -13,13 +13,14 @@ import re
|
|||||||
import shutil
|
import shutil
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys
|
||||||
import tarfile
|
import tarfile
|
||||||
import tempfile
|
import tempfile
|
||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable, Optional
|
from typing import Any, Callable, IO, Optional
|
||||||
from zoneinfo import ZoneInfo
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from modules.core.db_conn import DB_PATH, db_backend
|
from modules.core.db_conn import DB_PATH, db_backend
|
||||||
@@ -36,6 +37,8 @@ DEFAULT_KEEP_COUNT = 30
|
|||||||
DEFAULT_AUTO_HOUR = 3
|
DEFAULT_AUTO_HOUR = 3
|
||||||
CHECK_INTERVAL_SEC = 3600
|
CHECK_INTERVAL_SEC = 3600
|
||||||
_backup_lock = threading.Lock()
|
_backup_lock = threading.Lock()
|
||||||
|
RESTORE_STATUS_FILE = "restore_status.json"
|
||||||
|
RESTORE_CONFIRM_TOKEN = "RESTORE"
|
||||||
|
|
||||||
RESTORE_MD = """# qihuo 备份恢复说明
|
RESTORE_MD = """# qihuo 备份恢复说明
|
||||||
|
|
||||||
@@ -130,6 +133,14 @@ def default_restore_dir() -> str:
|
|||||||
return "/root/qihuo"
|
return "/root/qihuo"
|
||||||
|
|
||||||
|
|
||||||
|
def restore_target_dir() -> Path:
|
||||||
|
"""Web/API 恢复目标目录,默认当前应用根目录。"""
|
||||||
|
env = (os.getenv("QIHUO_RESTORE_DIR") or "").strip()
|
||||||
|
if env:
|
||||||
|
return Path(env)
|
||||||
|
return _app_root()
|
||||||
|
|
||||||
|
|
||||||
def backup_dir() -> Path:
|
def backup_dir() -> Path:
|
||||||
path = Path(default_backup_dir())
|
path = Path(default_backup_dir())
|
||||||
path.mkdir(parents=True, exist_ok=True)
|
path.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -140,6 +151,402 @@ def backup_in_progress() -> bool:
|
|||||||
return _backup_lock.locked()
|
return _backup_lock.locked()
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_status_path() -> Path:
|
||||||
|
from modules.core.paths import DATA_DIR
|
||||||
|
|
||||||
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
return DATA_DIR / RESTORE_STATUS_FILE
|
||||||
|
|
||||||
|
|
||||||
|
def _write_restore_status(state: str, message: str = "", **extra: Any) -> None:
|
||||||
|
payload = {
|
||||||
|
"state": state,
|
||||||
|
"message": message,
|
||||||
|
"updated_at": datetime.now(TZ).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
payload.update(extra)
|
||||||
|
_restore_status_path().write_text(
|
||||||
|
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_restore_status() -> dict:
|
||||||
|
path = _restore_status_path()
|
||||||
|
if not path.is_file():
|
||||||
|
return {"state": "idle", "message": "", "updated_at": ""}
|
||||||
|
try:
|
||||||
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
if isinstance(data, dict):
|
||||||
|
data.setdefault("state", "idle")
|
||||||
|
data.setdefault("message", "")
|
||||||
|
return data
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"state": "idle", "message": "", "updated_at": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def restore_in_progress() -> bool:
|
||||||
|
return get_restore_status().get("state") in ("pending", "running")
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_root_prefix(tar: tarfile.TarFile) -> str:
|
||||||
|
for member in tar.getmembers():
|
||||||
|
name = member.name.rstrip("/")
|
||||||
|
if name.endswith("/manifest.json") or name == "manifest.json":
|
||||||
|
if name == "manifest.json":
|
||||||
|
return ""
|
||||||
|
return name[: -len("/manifest.json")]
|
||||||
|
raise ValueError("备份包缺少 manifest.json")
|
||||||
|
|
||||||
|
|
||||||
|
def _read_manifest_from_tar(tar: tarfile.TarFile) -> dict:
|
||||||
|
root = _manifest_root_prefix(tar)
|
||||||
|
manifest_name = f"{root}/manifest.json" if root else "manifest.json"
|
||||||
|
try:
|
||||||
|
member = tar.getmember(manifest_name)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError("备份包缺少 manifest.json") from exc
|
||||||
|
extracted = tar.extractfile(member)
|
||||||
|
if not extracted:
|
||||||
|
raise ValueError("无法读取 manifest.json")
|
||||||
|
data = json.loads(extracted.read().decode("utf-8"))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise ValueError("manifest.json 格式无效")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_manifest(manifest: dict, *, current_backend: str | None = None) -> str:
|
||||||
|
if manifest.get("app") != "qihuo":
|
||||||
|
raise ValueError("不是有效的 qihuo 备份包")
|
||||||
|
backend = (manifest.get("backend") or "").strip()
|
||||||
|
if backend not in ("sqlite", "postgres"):
|
||||||
|
raise ValueError("manifest 缺少或无效的数据库类型")
|
||||||
|
if current_backend and backend != current_backend:
|
||||||
|
label = "PostgreSQL" if backend == "postgres" else "SQLite"
|
||||||
|
cur = "PostgreSQL" if current_backend == "postgres" else "SQLite"
|
||||||
|
raise ValueError(f"备份为 {label},当前服务为 {cur},无法恢复")
|
||||||
|
return backend
|
||||||
|
|
||||||
|
|
||||||
|
def _member_exists(tar: tarfile.TarFile, root: str, name: str) -> bool:
|
||||||
|
candidates = [name]
|
||||||
|
if root:
|
||||||
|
candidates.append(f"{root}/{name}")
|
||||||
|
return any(_tar_has_member(tar, path) for path in candidates)
|
||||||
|
|
||||||
|
|
||||||
|
def _tar_has_member(tar: tarfile.TarFile, path: str) -> bool:
|
||||||
|
try:
|
||||||
|
tar.getmember(path)
|
||||||
|
return True
|
||||||
|
except KeyError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_archive_contents(tar: tarfile.TarFile, manifest: dict, root: str) -> None:
|
||||||
|
backend = manifest["backend"]
|
||||||
|
if backend == "sqlite":
|
||||||
|
if not _member_exists(tar, root, "futures.db"):
|
||||||
|
raise ValueError("SQLite 备份缺少 futures.db")
|
||||||
|
elif not _member_exists(tar, root, "postgres_dump.sql"):
|
||||||
|
raise ValueError("PostgreSQL 备份缺少 postgres_dump.sql")
|
||||||
|
|
||||||
|
|
||||||
|
def _manifest_preview(manifest: dict, path: Path) -> dict:
|
||||||
|
backend = manifest.get("backend", "")
|
||||||
|
stat = path.stat()
|
||||||
|
created_at = (manifest.get("created_at") or "").strip()
|
||||||
|
return {
|
||||||
|
"name": path.name,
|
||||||
|
"backend": backend,
|
||||||
|
"backend_label": "PostgreSQL" if backend == "postgres" else "SQLite",
|
||||||
|
"created_at": created_at,
|
||||||
|
"includes_uploads": bool(manifest.get("includes_uploads")),
|
||||||
|
"includes_env": bool(manifest.get("includes_env")),
|
||||||
|
"env_restore_path": (manifest.get("env_restore_path") or "").strip(),
|
||||||
|
"size": stat.st_size,
|
||||||
|
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||||
|
"mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def peek_manifest(path: Path) -> dict:
|
||||||
|
with tarfile.open(path, "r:gz") as tar:
|
||||||
|
return _read_manifest_from_tar(tar)
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_backup_archive(path: Path, *, check_backend: bool = True) -> dict:
|
||||||
|
with tarfile.open(path, "r:gz") as tar:
|
||||||
|
manifest = _read_manifest_from_tar(tar)
|
||||||
|
current = db_backend() if check_backend else None
|
||||||
|
_validate_manifest(manifest, current_backend=current)
|
||||||
|
root = _manifest_root_prefix(tar)
|
||||||
|
_validate_archive_contents(tar, manifest, root)
|
||||||
|
return _manifest_preview(manifest, path)
|
||||||
|
|
||||||
|
|
||||||
|
def _allocate_backup_filename(manifest: dict, preferred: str = "") -> str:
|
||||||
|
preferred = (preferred or "").strip()
|
||||||
|
if preferred and BACKUP_FILENAME_RE.match(preferred):
|
||||||
|
candidate = backup_dir() / preferred
|
||||||
|
if not candidate.exists():
|
||||||
|
return preferred
|
||||||
|
created = (manifest.get("created_at") or "").strip()
|
||||||
|
stamp = ""
|
||||||
|
if created:
|
||||||
|
try:
|
||||||
|
stamp = datetime.fromisoformat(created).strftime("%Y%m%d_%H%M%S")
|
||||||
|
except ValueError:
|
||||||
|
stamp = ""
|
||||||
|
if not stamp:
|
||||||
|
stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S")
|
||||||
|
name = f"qihuo_backup_{stamp}.tar.gz"
|
||||||
|
if not (backup_dir() / name).exists():
|
||||||
|
return name
|
||||||
|
stamp = datetime.now(TZ).strftime("%Y%m%d_%H%M%S")
|
||||||
|
return f"qihuo_backup_{stamp}.tar.gz"
|
||||||
|
|
||||||
|
|
||||||
|
def save_uploaded_backup(stream: IO[bytes], original_filename: str = "") -> tuple[str, dict]:
|
||||||
|
with tempfile.NamedTemporaryFile(delete=False, suffix=".tar.gz") as tmp:
|
||||||
|
shutil.copyfileobj(stream, tmp)
|
||||||
|
tmp_path = Path(tmp.name)
|
||||||
|
try:
|
||||||
|
info = inspect_backup_archive(tmp_path, check_backend=True)
|
||||||
|
manifest = peek_manifest(tmp_path)
|
||||||
|
filename = _allocate_backup_filename(manifest, original_filename)
|
||||||
|
dest = backup_dir() / filename
|
||||||
|
shutil.move(str(tmp_path), str(dest))
|
||||||
|
info["name"] = filename
|
||||||
|
return filename, info
|
||||||
|
except Exception:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def _pm2_available() -> bool:
|
||||||
|
return shutil.which("pm2") is not None
|
||||||
|
|
||||||
|
|
||||||
|
def _pm2_stop() -> None:
|
||||||
|
if not _pm2_available():
|
||||||
|
logger.warning("pm2 not found, skip stop")
|
||||||
|
return
|
||||||
|
proc = subprocess.run(
|
||||||
|
["pm2", "stop", "qihuo"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
logger.warning("pm2 stop qihuo: %s", proc.stderr.strip() or proc.stdout.strip())
|
||||||
|
|
||||||
|
|
||||||
|
def _pm2_restart() -> None:
|
||||||
|
if not _pm2_available():
|
||||||
|
logger.warning("pm2 not found, skip restart")
|
||||||
|
return
|
||||||
|
proc = subprocess.run(
|
||||||
|
["pm2", "restart", "qihuo"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["pm2", "start", "qihuo"],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "pm2 restart 失败")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_member_to_path(tar: tarfile.TarFile, member_name: str, dest: Path) -> None:
|
||||||
|
try:
|
||||||
|
member = tar.getmember(member_name)
|
||||||
|
except KeyError:
|
||||||
|
return
|
||||||
|
extracted = tar.extractfile(member)
|
||||||
|
if not extracted:
|
||||||
|
return
|
||||||
|
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
with open(dest, "wb") as out:
|
||||||
|
shutil.copyfileobj(extracted, out)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_uploads_dir(tar: tarfile.TarFile, root: str, restore_dir: Path) -> None:
|
||||||
|
prefix = f"{root}/uploads" if root else "uploads"
|
||||||
|
uploads_dest = restore_dir / "uploads"
|
||||||
|
uploads_dest.mkdir(parents=True, exist_ok=True)
|
||||||
|
found = False
|
||||||
|
for member in tar.getmembers():
|
||||||
|
if member.name == prefix or member.name.startswith(prefix + "/"):
|
||||||
|
found = True
|
||||||
|
rel = member.name[len(prefix) :].lstrip("/")
|
||||||
|
if not rel:
|
||||||
|
continue
|
||||||
|
target = uploads_dest / rel
|
||||||
|
if member.isdir():
|
||||||
|
target.mkdir(parents=True, exist_ok=True)
|
||||||
|
else:
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
extracted = tar.extractfile(member)
|
||||||
|
if extracted:
|
||||||
|
with open(target, "wb") as out:
|
||||||
|
shutil.copyfileobj(extracted, out)
|
||||||
|
if not found:
|
||||||
|
logger.info("backup has no uploads/")
|
||||||
|
|
||||||
|
|
||||||
|
def _reload_env_file(env_path: Path) -> None:
|
||||||
|
if not env_path.is_file():
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(str(env_path), override=True)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("reload .env failed: %s", exc)
|
||||||
|
|
||||||
|
|
||||||
|
def _restore_postgres_dump(dump_path: Path) -> None:
|
||||||
|
url = (os.getenv("DATABASE_URL") or "").strip()
|
||||||
|
if not url:
|
||||||
|
raise RuntimeError("PostgreSQL 恢复需要 DATABASE_URL(请先恢复 .env 或检查环境变量)")
|
||||||
|
if not shutil.which("psql"):
|
||||||
|
raise RuntimeError("未找到 psql,请先安装 PostgreSQL 客户端")
|
||||||
|
proc = subprocess.run(
|
||||||
|
["psql", url, "-f", str(dump_path)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError(proc.stderr.strip() or proc.stdout.strip() or "psql 导入失败")
|
||||||
|
|
||||||
|
|
||||||
|
def _perform_restore(archive_path: Path, restore_dir: Path) -> dict:
|
||||||
|
restore_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
with tempfile.TemporaryDirectory(prefix="qihuo_restore_") as tmp:
|
||||||
|
work = Path(tmp)
|
||||||
|
with tarfile.open(archive_path, "r:gz") as tar:
|
||||||
|
manifest = _read_manifest_from_tar(tar)
|
||||||
|
backend = _validate_manifest(manifest, current_backend=db_backend())
|
||||||
|
root = _manifest_root_prefix(tar)
|
||||||
|
_validate_archive_contents(tar, manifest, root)
|
||||||
|
|
||||||
|
env_member = f"{root}/.env" if root else ".env"
|
||||||
|
env_restore_path = (manifest.get("env_restore_path") or "config/.env").strip()
|
||||||
|
if manifest.get("includes_env") and _tar_has_member(tar, env_member):
|
||||||
|
env_dest = restore_dir / env_restore_path
|
||||||
|
_extract_member_to_path(tar, env_member, env_dest)
|
||||||
|
_reload_env_file(env_dest)
|
||||||
|
|
||||||
|
if backend == "sqlite":
|
||||||
|
db_member = f"{root}/futures.db" if root else "futures.db"
|
||||||
|
db_dest = Path(DB_PATH)
|
||||||
|
if not db_dest.is_absolute():
|
||||||
|
db_dest = restore_dir / db_dest.name
|
||||||
|
_extract_member_to_path(tar, db_member, db_dest)
|
||||||
|
else:
|
||||||
|
dump_member = f"{root}/postgres_dump.sql" if root else "postgres_dump.sql"
|
||||||
|
dump_path = work / "postgres_dump.sql"
|
||||||
|
_extract_member_to_path(tar, dump_member, dump_path)
|
||||||
|
_restore_postgres_dump(dump_path)
|
||||||
|
|
||||||
|
_restore_uploads_dir(tar, root, restore_dir)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"backend": backend,
|
||||||
|
"restore_dir": str(restore_dir),
|
||||||
|
"includes_env": bool(manifest.get("includes_env")),
|
||||||
|
"includes_uploads": bool(manifest.get("includes_uploads")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_restore_job(archive_path: Path) -> None:
|
||||||
|
filename = archive_path.name
|
||||||
|
restore_dir = restore_target_dir()
|
||||||
|
try:
|
||||||
|
_write_restore_status(
|
||||||
|
"running",
|
||||||
|
"正在停止服务…",
|
||||||
|
filename=filename,
|
||||||
|
step="stop",
|
||||||
|
restore_dir=str(restore_dir),
|
||||||
|
)
|
||||||
|
_pm2_stop()
|
||||||
|
|
||||||
|
_write_restore_status(
|
||||||
|
"running",
|
||||||
|
"正在恢复数据…",
|
||||||
|
filename=filename,
|
||||||
|
step="restore",
|
||||||
|
restore_dir=str(restore_dir),
|
||||||
|
)
|
||||||
|
summary = _perform_restore(archive_path.resolve(), restore_dir)
|
||||||
|
|
||||||
|
_write_restore_status(
|
||||||
|
"running",
|
||||||
|
"正在重启服务…",
|
||||||
|
filename=filename,
|
||||||
|
step="restart",
|
||||||
|
restore_dir=str(restore_dir),
|
||||||
|
)
|
||||||
|
_pm2_restart()
|
||||||
|
|
||||||
|
_write_restore_status(
|
||||||
|
"done",
|
||||||
|
"恢复完成,服务已重启",
|
||||||
|
filename=filename,
|
||||||
|
restore_dir=str(restore_dir),
|
||||||
|
summary=summary,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("restore failed: %s", exc)
|
||||||
|
_write_restore_status(
|
||||||
|
"error",
|
||||||
|
str(exc),
|
||||||
|
filename=filename,
|
||||||
|
restore_dir=str(restore_dir),
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
_pm2_restart()
|
||||||
|
except Exception as restart_exc:
|
||||||
|
logger.warning("restart after restore error: %s", restart_exc)
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_restore(filename: str) -> tuple[bool, str]:
|
||||||
|
if _backup_lock.locked():
|
||||||
|
return False, "备份进行中,请稍后再试"
|
||||||
|
if restore_in_progress():
|
||||||
|
return False, "恢复进行中,请稍后再试"
|
||||||
|
try:
|
||||||
|
path = resolve_backup_file(filename)
|
||||||
|
inspect_backup_archive(path, check_backend=True)
|
||||||
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
return False, str(exc)
|
||||||
|
|
||||||
|
_write_restore_status(
|
||||||
|
"pending",
|
||||||
|
"恢复任务已提交…",
|
||||||
|
filename=filename,
|
||||||
|
restore_dir=str(restore_target_dir()),
|
||||||
|
)
|
||||||
|
script = Path(__file__).resolve().parent / "restore_job.py"
|
||||||
|
subprocess.Popen(
|
||||||
|
[sys.executable, str(script), str(path.resolve())],
|
||||||
|
start_new_session=True,
|
||||||
|
cwd=str(_app_root()),
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
return True, "恢复已开始,服务将短暂中断后自动重启"
|
||||||
|
|
||||||
|
|
||||||
def get_backup_last_at(get_setting: Callable[[str, str], str]) -> str:
|
def get_backup_last_at(get_setting: Callable[[str, str], str]) -> str:
|
||||||
return (get_setting(BACKUP_LAST_KEY, "") or "").strip()
|
return (get_setting(BACKUP_LAST_KEY, "") or "").strip()
|
||||||
|
|
||||||
@@ -316,20 +723,36 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]:
|
|||||||
return filename, f"备份已生成 {filename}({label},{size_mb:.2f} MB)"
|
return filename, f"备份已生成 {filename}({label},{size_mb:.2f} MB)"
|
||||||
|
|
||||||
|
|
||||||
def list_backups() -> list[dict]:
|
def list_backups(*, with_manifest: bool = True) -> list[dict]:
|
||||||
items: list[dict] = []
|
items: list[dict] = []
|
||||||
for path in sorted(backup_dir().glob("qihuo_backup_*.tar.gz"), reverse=True):
|
for path in sorted(backup_dir().glob("qihuo_backup_*.tar.gz"), reverse=True):
|
||||||
if not BACKUP_FILENAME_RE.match(path.name):
|
if not BACKUP_FILENAME_RE.match(path.name):
|
||||||
continue
|
continue
|
||||||
stat = path.stat()
|
stat = path.stat()
|
||||||
items.append(
|
item = {
|
||||||
{
|
|
||||||
"name": path.name,
|
"name": path.name,
|
||||||
"size": stat.st_size,
|
"size": stat.st_size,
|
||||||
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
"size_mb": round(stat.st_size / (1024 * 1024), 2),
|
||||||
"mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
|
"mtime": datetime.fromtimestamp(stat.st_mtime, TZ).isoformat(timespec="seconds"),
|
||||||
|
"backend": "",
|
||||||
|
"backend_label": "",
|
||||||
|
"created_at": "",
|
||||||
|
"includes_env": False,
|
||||||
|
"includes_uploads": False,
|
||||||
}
|
}
|
||||||
|
if with_manifest:
|
||||||
|
try:
|
||||||
|
manifest = peek_manifest(path)
|
||||||
|
item["backend"] = manifest.get("backend", "")
|
||||||
|
item["backend_label"] = (
|
||||||
|
"PostgreSQL" if manifest.get("backend") == "postgres" else "SQLite"
|
||||||
)
|
)
|
||||||
|
item["created_at"] = (manifest.get("created_at") or "").strip()
|
||||||
|
item["includes_env"] = bool(manifest.get("includes_env"))
|
||||||
|
item["includes_uploads"] = bool(manifest.get("includes_uploads"))
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug("read manifest %s: %s", path.name, exc)
|
||||||
|
items.append(item)
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
@@ -386,6 +809,8 @@ def schedule_backup(
|
|||||||
) -> tuple[bool, str]:
|
) -> tuple[bool, str]:
|
||||||
if _backup_lock.locked():
|
if _backup_lock.locked():
|
||||||
return False, "备份进行中,请稍后再试"
|
return False, "备份进行中,请稍后再试"
|
||||||
|
if restore_in_progress():
|
||||||
|
return False, "恢复进行中,请稍后再试"
|
||||||
|
|
||||||
def _run() -> None:
|
def _run() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Copyright (c) 2025-2026 马建军. All rights reserved.
|
||||||
|
"""Detached restore worker — survives pm2 stop of the parent web process."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> int:
|
||||||
|
args = argv if argv is not None else sys.argv[1:]
|
||||||
|
if len(args) != 1:
|
||||||
|
print("usage: python -m modules.backup.restore_job <backup.tar.gz>", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
archive = Path(args[0]).resolve()
|
||||||
|
if not archive.is_file():
|
||||||
|
print(f"backup not found: {archive}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
from modules.backup.db_backup import run_restore_job
|
||||||
|
|
||||||
|
run_restore_job(archive)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
+68
-44
@@ -3,55 +3,31 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from datetime import date, datetime
|
import logging
|
||||||
|
|
||||||
from flask import (
|
from flask import jsonify, request, send_file
|
||||||
Response,
|
|
||||||
flash,
|
logger = logging.getLogger(__name__)
|
||||||
jsonify,
|
|
||||||
redirect,
|
|
||||||
render_template,
|
|
||||||
request,
|
|
||||||
send_file,
|
|
||||||
session,
|
|
||||||
stream_with_context,
|
|
||||||
url_for,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def register(deps) -> None:
|
def register(deps) -> None:
|
||||||
app = deps.app
|
app = deps.app
|
||||||
login_required = deps.login_required
|
login_required = deps.login_required
|
||||||
require_nav = deps.require_nav
|
|
||||||
get_db = deps.get_db
|
|
||||||
get_setting = deps.get_setting
|
get_setting = deps.get_setting
|
||||||
set_setting = deps.set_setting
|
|
||||||
fetch_price = deps.fetch_price
|
|
||||||
send_wechat_msg = deps.send_wechat_msg
|
|
||||||
touch_stats_cache = deps.touch_stats_cache
|
|
||||||
get_stats_data = deps.get_stats_data
|
|
||||||
build_market_quote_payload = deps.build_market_quote_payload
|
|
||||||
today_str = deps.today_str
|
|
||||||
expire_old_plans = deps.expire_old_plans
|
|
||||||
TZ = deps.tz
|
|
||||||
DB_PATH = deps.db_path
|
|
||||||
UPLOAD_DIR = deps.upload_dir
|
|
||||||
OPEN_TYPES = deps.open_types
|
|
||||||
EXIT_TRIGGERS = deps.exit_triggers
|
|
||||||
BEHAVIOR_TAGS = deps.behavior_tags
|
|
||||||
KLINE_PERIODS = deps.kline_periods
|
|
||||||
KLINE_CUTOFFS = deps.kline_cutoffs
|
|
||||||
calc_holding_duration = deps.calc_holding_duration
|
|
||||||
holding_to_minutes = deps.holding_to_minutes
|
|
||||||
classify_close_result = deps.classify_close_result
|
|
||||||
calc_rr_ratio = deps.calc_rr_ratio
|
|
||||||
calc_theoretical_pnl = deps.calc_theoretical_pnl
|
|
||||||
parse_review_date_filter = deps.parse_review_date_filter
|
|
||||||
_trading_mode = deps.trading_mode
|
|
||||||
_ua_is_phone = deps.ua_is_phone
|
|
||||||
_static_asset_v = deps.static_asset_v
|
|
||||||
|
|
||||||
from modules.backup.db_backup import list_backups, resolve_backup_file
|
from modules.backup.db_backup import (
|
||||||
|
RESTORE_CONFIRM_TOKEN,
|
||||||
|
backup_dir,
|
||||||
|
backup_in_progress,
|
||||||
|
get_backup_last_at,
|
||||||
|
get_restore_status,
|
||||||
|
inspect_backup_archive,
|
||||||
|
list_backups,
|
||||||
|
resolve_backup_file,
|
||||||
|
restore_in_progress,
|
||||||
|
save_uploaded_backup,
|
||||||
|
schedule_restore,
|
||||||
|
)
|
||||||
|
|
||||||
@app.route("/api/backup/list")
|
@app.route("/api/backup/list")
|
||||||
@login_required
|
@login_required
|
||||||
@@ -61,18 +37,66 @@ def register(deps) -> None:
|
|||||||
"dir": str(backup_dir()),
|
"dir": str(backup_dir()),
|
||||||
"last_at": get_backup_last_at(get_setting),
|
"last_at": get_backup_last_at(get_setting),
|
||||||
"running": backup_in_progress(),
|
"running": backup_in_progress(),
|
||||||
|
"restore": get_restore_status(),
|
||||||
"items": list_backups(),
|
"items": list_backups(),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/backup/download/<filename>")
|
@app.route("/api/backup/download/<filename>")
|
||||||
@login_required
|
@login_required
|
||||||
def api_backup_download(filename):
|
def api_backup_download(filename):
|
||||||
from flask import send_file
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
path = resolve_backup_file(filename)
|
path = resolve_backup_file(filename)
|
||||||
except (ValueError, FileNotFoundError) as exc:
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
return jsonify({"error": str(exc)}), 404
|
return jsonify({"error": str(exc)}), 404
|
||||||
return send_file(path, as_attachment=True, download_name=path.name)
|
return send_file(path, as_attachment=True, download_name=path.name)
|
||||||
|
|
||||||
|
@app.route("/api/backup/upload", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_backup_upload():
|
||||||
|
if backup_in_progress():
|
||||||
|
return jsonify({"error": "备份进行中,请稍后再试"}), 409
|
||||||
|
if restore_in_progress():
|
||||||
|
return jsonify({"error": "恢复进行中,请稍后再试"}), 409
|
||||||
|
upload = request.files.get("file")
|
||||||
|
if not upload or not upload.filename:
|
||||||
|
return jsonify({"error": "请选择备份文件"}), 400
|
||||||
|
if not upload.filename.lower().endswith(".tar.gz"):
|
||||||
|
return jsonify({"error": "仅支持 .tar.gz 备份包"}), 400
|
||||||
|
try:
|
||||||
|
name, info = save_uploaded_backup(upload.stream, upload.filename)
|
||||||
|
return jsonify({"ok": True, "name": name, "info": info})
|
||||||
|
except ValueError as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 400
|
||||||
|
except Exception:
|
||||||
|
logger.exception("backup upload failed")
|
||||||
|
return jsonify({"error": "上传失败,请检查备份包是否完整"}), 500
|
||||||
|
|
||||||
|
@app.route("/api/backup/info/<filename>")
|
||||||
|
@login_required
|
||||||
|
def api_backup_info(filename):
|
||||||
|
try:
|
||||||
|
path = resolve_backup_file(filename)
|
||||||
|
return jsonify(inspect_backup_archive(path, check_backend=True))
|
||||||
|
except (ValueError, FileNotFoundError) as exc:
|
||||||
|
return jsonify({"error": str(exc)}), 404
|
||||||
|
|
||||||
|
@app.route("/api/backup/restore", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_backup_restore():
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
filename = (data.get("filename") or request.form.get("filename") or "").strip()
|
||||||
|
confirm = (data.get("confirm") or request.form.get("confirm") or "").strip()
|
||||||
|
if confirm != RESTORE_CONFIRM_TOKEN:
|
||||||
|
return jsonify({"error": "请确认恢复操作"}), 400
|
||||||
|
if not filename:
|
||||||
|
return jsonify({"error": "缺少备份文件名"}), 400
|
||||||
|
ok, msg = schedule_restore(filename)
|
||||||
|
if ok:
|
||||||
|
return jsonify({"ok": True, "message": msg}), 202
|
||||||
|
return jsonify({"error": msg}), 409
|
||||||
|
|
||||||
|
@app.route("/api/backup/restore/status")
|
||||||
|
@login_required
|
||||||
|
def api_backup_restore_status():
|
||||||
|
return jsonify(get_restore_status())
|
||||||
|
|||||||
@@ -56,9 +56,11 @@ def register(deps) -> None:
|
|||||||
from modules.backup.db_backup import (
|
from modules.backup.db_backup import (
|
||||||
backup_dir,
|
backup_dir,
|
||||||
backup_in_progress,
|
backup_in_progress,
|
||||||
default_restore_dir,
|
|
||||||
get_backup_last_at,
|
get_backup_last_at,
|
||||||
|
get_restore_status,
|
||||||
list_backups,
|
list_backups,
|
||||||
|
restore_in_progress,
|
||||||
|
restore_target_dir,
|
||||||
schedule_backup,
|
schedule_backup,
|
||||||
)
|
)
|
||||||
from modules.market.market import get_quote_source_label
|
from modules.market.market import get_quote_source_label
|
||||||
@@ -300,7 +302,9 @@ def register(deps) -> None:
|
|||||||
backup_auto_enabled=get_setting("backup_auto_enabled", "1") == "1",
|
backup_auto_enabled=get_setting("backup_auto_enabled", "1") == "1",
|
||||||
backup_auto_hour=get_setting("backup_auto_hour", "3"),
|
backup_auto_hour=get_setting("backup_auto_hour", "3"),
|
||||||
backup_keep_count=get_setting("backup_keep_count", "30"),
|
backup_keep_count=get_setting("backup_keep_count", "30"),
|
||||||
backup_restore_dir=default_restore_dir(),
|
backup_restore_dir=str(restore_target_dir()),
|
||||||
|
backup_restore_running=restore_in_progress(),
|
||||||
|
restore_status=get_restore_status(),
|
||||||
ai_enabled=get_setting("ai_enabled", "0") == "1",
|
ai_enabled=get_setting("ai_enabled", "0") == "1",
|
||||||
ai_provider=get_setting("ai_provider", "ollama"),
|
ai_provider=get_setting("ai_provider", "ollama"),
|
||||||
ai_ollama_base_url=get_setting("ai_ollama_base_url", "http://127.0.0.1:11434"),
|
ai_ollama_base_url=get_setting("ai_ollama_base_url", "http://127.0.0.1:11434"),
|
||||||
|
|||||||
@@ -116,6 +116,140 @@
|
|||||||
});
|
});
|
||||||
loadCtpFoldState();
|
loadCtpFoldState();
|
||||||
|
|
||||||
|
function initBackupPanel() {
|
||||||
|
var uploadBtn = document.getElementById('backup-upload-btn');
|
||||||
|
var uploadInput = document.getElementById('backup-upload-file');
|
||||||
|
var statusEl = document.getElementById('backup-restore-status');
|
||||||
|
var pollTimer = null;
|
||||||
|
|
||||||
|
function setRestoreStatus(data) {
|
||||||
|
if (!statusEl || !data) return;
|
||||||
|
var state = data.state || 'idle';
|
||||||
|
statusEl.hidden = state === 'idle';
|
||||||
|
statusEl.classList.remove('is-running', 'is-error', 'is-done');
|
||||||
|
if (state === 'pending' || state === 'running') {
|
||||||
|
statusEl.classList.add('is-running');
|
||||||
|
statusEl.textContent = data.message || '恢复进行中…';
|
||||||
|
} else if (state === 'done') {
|
||||||
|
statusEl.classList.add('is-done');
|
||||||
|
statusEl.textContent = data.message || '恢复完成';
|
||||||
|
} else if (state === 'error') {
|
||||||
|
statusEl.classList.add('is-error');
|
||||||
|
statusEl.textContent = '恢复失败:' + (data.message || '未知错误');
|
||||||
|
} else {
|
||||||
|
statusEl.textContent = data.message || '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setBusy(busy) {
|
||||||
|
document.querySelectorAll('[data-backup-restore], #backup-upload-btn').forEach(function (btn) {
|
||||||
|
btn.disabled = !!busy;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function pollRestoreStatus() {
|
||||||
|
fetch('/api/backup/restore/status', { credentials: 'same-origin' })
|
||||||
|
.then(function (res) { return res.json(); })
|
||||||
|
.then(function (data) {
|
||||||
|
setRestoreStatus(data);
|
||||||
|
var active = data.state === 'pending' || data.state === 'running';
|
||||||
|
setBusy(active);
|
||||||
|
if (active) {
|
||||||
|
if (!pollTimer) {
|
||||||
|
pollTimer = window.setInterval(pollRestoreStatus, 2500);
|
||||||
|
}
|
||||||
|
} else if (pollTimer) {
|
||||||
|
window.clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
if (data.state === 'done') {
|
||||||
|
window.setTimeout(function () { window.location.reload(); }, 1200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function () { /* ignore */ });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadBtn && uploadInput && !uploadBtn.dataset.settingsBound) {
|
||||||
|
uploadBtn.dataset.settingsBound = '1';
|
||||||
|
uploadBtn.addEventListener('click', function () {
|
||||||
|
var file = uploadInput.files && uploadInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
window.alert('请先选择 .tar.gz 备份文件');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!/\.tar\.gz$/i.test(file.name)) {
|
||||||
|
window.alert('仅支持 .tar.gz 格式');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var form = new FormData();
|
||||||
|
form.append('file', file);
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
uploadBtn.textContent = '上传中…';
|
||||||
|
fetch('/api/backup/upload', { method: 'POST', body: form, credentials: 'same-origin' })
|
||||||
|
.then(function (res) { return res.json().then(function (body) { return { ok: res.ok, body: body }; }); })
|
||||||
|
.then(function (result) {
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error((result.body && result.body.error) || '上传失败');
|
||||||
|
}
|
||||||
|
window.alert('上传成功:' + (result.body.name || file.name));
|
||||||
|
window.location.reload();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
window.alert(err.message || '上传失败');
|
||||||
|
})
|
||||||
|
.finally(function () {
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
uploadBtn.textContent = '上传并校验';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('[data-backup-restore]').forEach(function (btn) {
|
||||||
|
if (btn.dataset.settingsBound) return;
|
||||||
|
btn.dataset.settingsBound = '1';
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var name = btn.getAttribute('data-backup-restore') || '';
|
||||||
|
if (!name) return;
|
||||||
|
var ok = window.confirm(
|
||||||
|
'确定要恢复备份「' + name + '」吗?\n\n'
|
||||||
|
+ '将停止服务并覆盖当前数据库、uploads 与 .env,完成后自动重启。\n'
|
||||||
|
+ '此操作不可撤销,请确认已做好当前数据备份。'
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
var typed = window.prompt('请输入 RESTORE 确认恢复:');
|
||||||
|
if (typed !== 'RESTORE') {
|
||||||
|
if (typed !== null) window.alert('确认文字不正确,已取消');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
fetch('/api/backup/restore', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ filename: name, confirm: 'RESTORE' })
|
||||||
|
})
|
||||||
|
.then(function (res) { return res.json().then(function (body) { return { ok: res.ok, body: body }; }); })
|
||||||
|
.then(function (result) {
|
||||||
|
if (!result.ok) {
|
||||||
|
throw new Error((result.body && result.body.error) || '恢复启动失败');
|
||||||
|
}
|
||||||
|
setRestoreStatus({ state: 'pending', message: result.body.message || '恢复已开始…' });
|
||||||
|
setBusy(true);
|
||||||
|
pollRestoreStatus();
|
||||||
|
})
|
||||||
|
.catch(function (err) {
|
||||||
|
window.alert(err.message || '恢复启动失败');
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (statusEl && !statusEl.hidden) {
|
||||||
|
pollRestoreStatus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
initBackupPanel();
|
||||||
|
|
||||||
var ctpForm = document.getElementById('ctp-settings-form');
|
var ctpForm = document.getElementById('ctp-settings-form');
|
||||||
if (ctpForm && !ctpForm.dataset.settingsBound) {
|
if (ctpForm && !ctpForm.dataset.settingsBound) {
|
||||||
ctpForm.dataset.settingsBound = '1';
|
ctpForm.dataset.settingsBound = '1';
|
||||||
|
|||||||
@@ -72,6 +72,26 @@
|
|||||||
.settings-backup-restore summary{cursor:pointer;color:var(--text-title);font-weight:600}
|
.settings-backup-restore summary{cursor:pointer;color:var(--text-title);font-weight:600}
|
||||||
.settings-backup-meta{font-size:.82rem;color:var(--text-muted);line-height:1.55;margin:.35rem 0 .65rem}
|
.settings-backup-meta{font-size:.82rem;color:var(--text-muted);line-height:1.55;margin:.35rem 0 .65rem}
|
||||||
.settings-backup-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .65rem}
|
.settings-backup-actions{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem .65rem}
|
||||||
|
.settings-backup-upload{
|
||||||
|
margin-top:.65rem;padding:.65rem .75rem;border-radius:8px;
|
||||||
|
border:1px dashed var(--border);background:var(--card-inner);
|
||||||
|
}
|
||||||
|
.settings-backup-upload label{font-size:.82rem;color:var(--text-muted);display:block;margin-bottom:.4rem}
|
||||||
|
.settings-backup-upload-row{display:flex;flex-wrap:wrap;align-items:center;gap:.45rem .6rem}
|
||||||
|
.settings-backup-upload input[type=file]{font-size:.78rem;max-width:100%}
|
||||||
|
.settings-backup-status{
|
||||||
|
margin-top:.55rem;padding:.55rem .7rem;border-radius:8px;font-size:.82rem;line-height:1.5;
|
||||||
|
border:1px solid var(--border);background:var(--card-inner);color:var(--text-muted);
|
||||||
|
}
|
||||||
|
.settings-backup-status.is-running{border-color:var(--accent);color:var(--text-title)}
|
||||||
|
.settings-backup-status.is-error{border-color:#c44;color:#c44}
|
||||||
|
.settings-backup-status.is-done{border-color:#3a8;color:#3a8}
|
||||||
|
.settings-backup-restore-btn{
|
||||||
|
border:1px solid #c44;background:transparent;color:#c44;cursor:pointer;
|
||||||
|
padding:.2rem .45rem;border-radius:6px;font-size:.75rem;font-weight:600;
|
||||||
|
}
|
||||||
|
.settings-backup-restore-btn:hover{background:rgba(204,68,68,.08)}
|
||||||
|
.settings-backup-restore-btn:disabled{opacity:.45;cursor:not-allowed}
|
||||||
.settings-backup-download{color:var(--accent);text-decoration:none;font-weight:600}
|
.settings-backup-download{color:var(--accent);text-decoration:none;font-weight:600}
|
||||||
.settings-backup-download:hover{text-decoration:underline}
|
.settings-backup-download:hover{text-decoration:underline}
|
||||||
.settings-admin-row .settings-compact-card{font-size:.78rem}
|
.settings-admin-row .settings-compact-card{font-size:.78rem}
|
||||||
@@ -508,6 +528,7 @@
|
|||||||
自动备份目录:<code>{{ backup_dir }}</code>
|
自动备份目录:<code>{{ backup_dir }}</code>
|
||||||
{% if backup_last_at %} · 上次备份 {{ backup_last_at.replace('T', ' ') }}{% else %} · 尚未备份{% endif %}
|
{% if backup_last_at %} · 上次备份 {{ backup_last_at.replace('T', ' ') }}{% else %} · 尚未备份{% endif %}
|
||||||
{% if backup_running %} · <span style="color:var(--accent)">备份进行中…</span>{% endif %}
|
{% if backup_running %} · <span style="color:var(--accent)">备份进行中…</span>{% endif %}
|
||||||
|
{% if backup_restore_running %} · <span style="color:#c44">恢复进行中…</span>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
<form action="{{ url_for('settings') }}" method="post" style="margin-bottom:.55rem">
|
<form action="{{ url_for('settings') }}" method="post" style="margin-bottom:.55rem">
|
||||||
<input type="hidden" name="action" value="backup_config">
|
<input type="hidden" name="action" value="backup_config">
|
||||||
@@ -532,23 +553,47 @@
|
|||||||
<div class="settings-backup-actions">
|
<div class="settings-backup-actions">
|
||||||
<form action="{{ url_for('settings') }}" method="post">
|
<form action="{{ url_for('settings') }}" method="post">
|
||||||
<input type="hidden" name="action" value="backup_now">
|
<input type="hidden" name="action" value="backup_now">
|
||||||
<button type="submit" class="btn-primary" {% if backup_running %}disabled{% endif %}>立即备份</button>
|
<button type="submit" class="btn-primary" {% if backup_running or backup_restore_running %}disabled{% endif %}>立即备份</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code>、<code>uploads/</code>、<code>.env</code>,默认恢复至 <code>{{ backup_restore_dir }}</code>。</p>
|
<div class="settings-backup-upload" id="backup-upload-panel">
|
||||||
|
<label for="backup-upload-file">上传备份包(.tar.gz)</label>
|
||||||
|
<div class="settings-backup-upload-row">
|
||||||
|
<input id="backup-upload-file" type="file" accept=".tar.gz,application/gzip,application/x-gzip">
|
||||||
|
<button type="button" class="btn-primary" id="backup-upload-btn" {% if backup_running or backup_restore_running %}disabled{% endif %}>上传并校验</button>
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin:.45rem 0 0">上传后会校验 manifest 与包结构,通过后加入下方列表。</p>
|
||||||
|
</div>
|
||||||
|
<div class="settings-backup-status{% if restore_status.state in ('pending','running') %} is-running{% elif restore_status.state == 'error' %} is-error{% elif restore_status.state == 'done' %} is-done{% endif %}" id="backup-restore-status"{% if restore_status.state == 'idle' %} hidden{% endif %}>
|
||||||
|
{% if restore_status.state in ('pending','running') %}
|
||||||
|
{{ restore_status.message or '恢复进行中…' }}
|
||||||
|
{% elif restore_status.state == 'done' %}
|
||||||
|
{{ restore_status.message or '恢复完成' }}
|
||||||
|
{% elif restore_status.state == 'error' %}
|
||||||
|
恢复失败:{{ restore_status.message or '未知错误' }}
|
||||||
|
{% else %}
|
||||||
|
{{ restore_status.message }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code> / <code>postgres_dump.sql</code>、<code>uploads/</code>、<code>.env</code>。网页恢复目标:<code>{{ backup_restore_dir }}</code>。</p>
|
||||||
|
|
||||||
{% if backup_items %}
|
{% if backup_items %}
|
||||||
<table class="settings-backup-table">
|
<table class="settings-backup-table" id="backup-items-table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr><th>文件名</th><th>大小</th><th>时间</th><th></th></tr>
|
<tr><th>文件名</th><th>类型</th><th>.env</th><th>大小</th><th>时间</th><th>操作</th></tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for item in backup_items %}
|
{% for item in backup_items %}
|
||||||
<tr>
|
<tr data-backup-name="{{ item.name }}">
|
||||||
<td><code>{{ item.name }}</code></td>
|
<td><code>{{ item.name }}</code></td>
|
||||||
|
<td>{{ item.backend_label or '—' }}</td>
|
||||||
|
<td>{% if item.includes_env %}有{% else %}—{% endif %}</td>
|
||||||
<td>{{ item.size_mb }} MB</td>
|
<td>{{ item.size_mb }} MB</td>
|
||||||
<td>{{ item.mtime.replace('T', ' ')[:16] }}</td>
|
<td>{{ (item.created_at or item.mtime).replace('T', ' ')[:16] }}</td>
|
||||||
<td><a href="{{ url_for('api_backup_download', filename=item.name) }}" class="settings-backup-download">下载</a></td>
|
<td class="settings-backup-actions" style="margin:0">
|
||||||
|
<a href="{{ url_for('api_backup_download', filename=item.name) }}" class="settings-backup-download">下载</a>
|
||||||
|
<button type="button" class="settings-backup-restore-btn" data-backup-restore="{{ item.name }}" {% if backup_running or backup_restore_running %}disabled{% endif %}>恢复此备份</button>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -560,11 +605,10 @@
|
|||||||
<details class="settings-backup-restore">
|
<details class="settings-backup-restore">
|
||||||
<summary>备份恢复说明</summary>
|
<summary>备份恢复说明</summary>
|
||||||
<ol style="margin:.5rem 0 0 1rem;padding:0">
|
<ol style="margin:.5rem 0 0 1rem;padding:0">
|
||||||
<li>下载 <code>.tar.gz</code> 到目标服务器(如 <code>/root/</code>)。</li>
|
<li>可在上方上传 <code>.tar.gz</code>,或从列表下载备份到本机。</li>
|
||||||
<li>解压:<code>tar -xzf qihuo_backup_*.tar.gz</code></li>
|
<li>点击「恢复此备份」会停止服务、还原数据库 / <code>uploads/</code> / <code>.env</code>,然后自动重启。</li>
|
||||||
<li>执行:<code>chmod +x restore.sh && ./restore.sh</code></li>
|
<li>恢复前请确认备份类型与当前服务一致(SQLite / PostgreSQL)。</li>
|
||||||
<li>指定目录:<code>RESTORE_DIR=/opt/qihuo ./restore.sh</code></li>
|
<li>也可在服务器手工执行包内 <code>restore.sh</code>(见 <code>RESTORE_DIR</code>)。</li>
|
||||||
<li>恢复脚本会自动还原数据库、<code>uploads/</code> 与 <code>.env</code>,然后重启服务。</li>
|
|
||||||
</ol>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|||||||
Reference in New Issue
Block a user