中控备份支持上传 tar.gz 后再恢复,便于换机迁移。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-05 18:45:37 +08:00
parent e18dd41c2d
commit 02ca76ea7c
4 changed files with 541 additions and 442 deletions
+11
View File
@@ -36,6 +36,7 @@ from lib.backup_lib import (
create_backup as create_hub_backup, create_backup as create_hub_backup,
list_backups as list_hub_backups, list_backups as list_hub_backups,
restore_backup as restore_hub_backup, restore_backup as restore_hub_backup,
save_uploaded_backup as save_hub_uploaded_backup,
start_backup_scheduler as start_hub_backup_scheduler, start_backup_scheduler as start_hub_backup_scheduler,
) )
from lib.bridge_client_lib import ( from lib.bridge_client_lib import (
@@ -397,6 +398,16 @@ def api_hub_backup_restore():
return jsonify(result), (200 if result.get("ok") else 400) return jsonify(result), (200 if result.get("ok") else 400)
@app.route("/api/settings/backup/hub/upload", methods=["POST"])
@login_required
def api_hub_backup_upload():
fs = request.files.get("file")
if fs is None or not getattr(fs, "filename", None):
return jsonify({"ok": False, "msg": "请选择备份文件"}), 400
result = save_hub_uploaded_backup(fs)
return jsonify(result), (200 if result.get("ok") else 400)
@app.route("/api/settings/backup/instances", methods=["GET"]) @app.route("/api/settings/backup/instances", methods=["GET"])
@login_required @login_required
def api_backup_instances(): def api_backup_instances():
+56
View File
@@ -175,6 +175,62 @@ def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) ->
} }
_MAX_UPLOAD_BYTES = 32 * 1024 * 1024
def save_uploaded_backup(file_storage: Any) -> dict[str, Any]:
"""把本机下载的中控备份包写入备份目录,不自动恢复."""
filename = os.path.basename(str(getattr(file_storage, "filename", "") or "").strip())
safe = _safe_backup_name(filename)
if not safe:
return {
"ok": False,
"msg": "文件名须为 crypto_okx_hub_backup_YYYYMMDD_HHMMSS.tar.gz",
}
dest = os.path.join(backup_dir(), safe)
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_up_")
os.close(tmp_fd)
try:
file_storage.save(tmp_path)
total = os.path.getsize(tmp_path)
if total <= 0:
os.remove(tmp_path)
return {"ok": False, "msg": "上传文件为空"}
if total > _MAX_UPLOAD_BYTES:
os.remove(tmp_path)
return {"ok": False, "msg": "备份文件超过 32MB"}
with tarfile.open(tmp_path, "r:gz") as tar:
names = tar.getnames()
has_env = any(n.replace("\\", "/").rstrip("/") in (".env", "./.env") for n in names)
has_db = any(
n.replace("\\", "/").startswith("database/") and n.replace("\\", "/") != "database/"
for n in names
)
if not has_env and not has_db:
os.remove(tmp_path)
return {"ok": False, "msg": "不是中控备份包(缺少 .env / database)"}
shutil.move(tmp_path, dest)
except tarfile.TarError:
try:
os.remove(tmp_path)
except OSError:
pass
return {"ok": False, "msg": "无法读取 tar.gz"}
except Exception as exc:
try:
os.remove(tmp_path)
except OSError:
pass
return {"ok": False, "msg": str(exc) or "上传失败"}
st = os.stat(dest)
return {
"ok": True,
"name": safe,
"size": int(st.st_size),
"msg": "已上传,请在列表中点恢复",
}
def maybe_run_auto_backup( def maybe_run_auto_backup(
*, *,
root_dir: str, root_dir: str,
+28
View File
@@ -187,13 +187,41 @@
} }
} }
async function uploadHubBackup(file) {
if (!file) return;
setText("hub-backup-status", "上传中…");
try {
const fd = new FormData();
fd.append("file", file, file.name);
const r = await fetch("/api/settings/backup/hub/upload", {
method: "POST",
credentials: "same-origin",
body: fd,
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "上传失败");
setText("hub-backup-status", j.msg || ("已上传 " + (j.name || "")));
await loadHubList();
} catch (e) {
setText("hub-backup-status", e.message || "上传失败", true);
}
}
function boot() { function boot() {
const createBtn = document.getElementById("hub-backup-create"); const createBtn = document.getElementById("hub-backup-create");
const hubRefresh = document.getElementById("hub-backup-refresh"); const hubRefresh = document.getElementById("hub-backup-refresh");
const uploadInput = document.getElementById("hub-backup-upload");
const clientRefresh = document.getElementById("client-backup-refresh"); const clientRefresh = document.getElementById("client-backup-refresh");
const sel = document.getElementById("client-backup-instance"); const sel = document.getElementById("client-backup-instance");
if (createBtn) createBtn.addEventListener("click", createHubBackup); if (createBtn) createBtn.addEventListener("click", createHubBackup);
if (hubRefresh) hubRefresh.addEventListener("click", loadHubList); if (hubRefresh) hubRefresh.addEventListener("click", loadHubList);
if (uploadInput) {
uploadInput.addEventListener("change", function () {
const f = uploadInput.files && uploadInput.files[0];
uploadInput.value = "";
void uploadHubBackup(f);
});
}
if (clientRefresh) clientRefresh.addEventListener("click", loadClientList); if (clientRefresh) clientRefresh.addEventListener("click", loadClientList);
if (sel) sel.addEventListener("change", loadClientList); if (sel) sel.addEventListener("change", loadClientList);
const hubBody = document.getElementById("hub-backup-body"); const hubBody = document.getElementById("hub-backup-body");
+6 -2
View File
@@ -103,10 +103,14 @@
<div class="backup-block"> <div class="backup-block">
<div class="backup-block-head"> <div class="backup-block-head">
<h3>中控备份</h3> <h3>中控备份</h3>
<p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code> 与 <code>hub.db</code></p> <p class="muted field-hint">目录 <code id="hub-backup-dir">/root/crypto_okx_hub_backups</code> · 含 <code>.env</code> 与 <code>hub.db</code>。换机时先上传家里下载的包,再点恢复。</p>
<div class="settings-actions"> <div class="settings-actions">
<button type="button" class="settings-primary" id="hub-backup-create">立即备份</button> <button type="button" class="settings-primary" id="hub-backup-create">立即备份</button>
<button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button> <button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button>
<label class="btn-secondary" style="cursor:pointer;display:inline-block">
上传备份
<input type="file" id="hub-backup-upload" accept=".gz,.tar.gz,application/gzip" hidden>
</label>
<span class="muted" id="hub-backup-status" style="font-size:.8rem"></span> <span class="muted" id="hub-backup-status" style="font-size:.8rem"></span>
</div> </div>
</div> </div>
@@ -184,7 +188,7 @@
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=2"></script> <script src="{{ url_for('static', filename='settings_backup.js') }}?v=3"></script>
<script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script> <script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script>
<script> <script>
HubSettingsBackup.boot(); HubSettingsBackup.boot();