系统设置增加备份恢复:中控本机备份,并可下载各客户端备份。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-04 06:50:44 +08:00
parent 9f3a1f5b66
commit a983fff6e0
7 changed files with 720 additions and 2 deletions
+134 -1
View File
@@ -11,11 +11,13 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
from flask import ( from flask import (
Flask, Flask,
Response,
flash, flash,
jsonify, jsonify,
redirect, redirect,
render_template, render_template,
request, request,
send_file,
session, session,
url_for, url_for,
) )
@@ -27,8 +29,19 @@ if str(ROOT) not in sys.path:
ENV_PATH = ROOT / ".env" ENV_PATH = ROOT / ".env"
load_dotenv(ENV_PATH) load_dotenv(ENV_PATH)
from lib.backup_lib import (
backup_dir as hub_backup_dir,
backup_keep_days as hub_backup_keep_days,
backup_path as hub_backup_path,
create_backup as create_hub_backup,
list_backups as list_hub_backups,
restore_backup as restore_hub_backup,
start_backup_scheduler as start_hub_backup_scheduler,
)
from lib.bridge_client_lib import ( from lib.bridge_client_lib import (
dispatch_cond_order_to_ids, dispatch_cond_order_to_ids,
fetch_backup_file,
fetch_backup_list,
fetch_cond_orders, fetch_cond_orders,
post_close_full, post_close_full,
post_cond_cancel, post_cond_cancel,
@@ -36,7 +49,7 @@ from lib.bridge_client_lib import (
pull_all_enabled, pull_all_enabled,
) )
from lib.cash_sample_lib import record_cash_samples_from_overview from lib.cash_sample_lib import record_cash_samples_from_overview
from lib.db_lib import init_db from lib.db_lib import db_path as hub_db_path, init_db
from lib.env_lib import apply_env_updates, env_get from lib.env_lib import apply_env_updates, env_get
from lib.health_watch_lib import inspect_overview_rows from lib.health_watch_lib import inspect_overview_rows
from lib.instances_lib import ( from lib.instances_lib import (
@@ -309,6 +322,116 @@ def settings_page():
) )
@app.route("/api/settings/backup/hub/list", methods=["GET"])
@login_required
def api_hub_backup_list():
return jsonify(
{
"ok": True,
"items": list_hub_backups(),
"backup_dir": hub_backup_dir(),
"keep_days": hub_backup_keep_days(),
"auto_hour_bj": 3,
"includes": [".env", "database"],
}
)
def _resolved_hub_db_path() -> str:
p = hub_db_path()
if os.path.isabs(p):
return p
return str(ROOT / p)
@app.route("/api/settings/backup/hub/create", methods=["POST"])
@login_required
def api_hub_backup_create():
try:
result = create_hub_backup(
root_dir=str(ROOT),
env_path=str(ENV_PATH),
db_path=_resolved_hub_db_path(),
label="manual",
)
return jsonify(result)
except Exception as exc:
return jsonify({"ok": False, "msg": str(exc) or "备份失败"}), 500
@app.route("/api/settings/backup/hub/download/<name>", methods=["GET"])
@login_required
def api_hub_backup_download(name: str):
path = hub_backup_path(name)
if not path or not os.path.isfile(path):
return jsonify({"ok": False, "msg": "备份不存在"}), 404
return send_file(
path,
mimetype="application/gzip",
as_attachment=True,
download_name=os.path.basename(path),
)
@app.route("/api/settings/backup/hub/restore", methods=["POST"])
@login_required
def api_hub_backup_restore():
data = request.get_json(silent=True) or {}
name = str(data.get("name") or "").strip()
if not name:
return jsonify({"ok": False, "msg": "缺少备份文件名"}), 400
result = restore_hub_backup(
name,
root_dir=str(ROOT),
env_path=str(ENV_PATH),
db_path=_resolved_hub_db_path(),
)
return jsonify(result), (200 if result.get("ok") else 400)
@app.route("/api/settings/backup/instances", methods=["GET"])
@login_required
def api_backup_instances():
items = [
{
"id": i["id"],
"name": i.get("name") or f"#{i['id']}",
"enabled": bool(i.get("enabled")),
}
for i in list_instances(enabled_only=False)
]
return jsonify({"ok": True, "items": items})
@app.route("/api/settings/backup/client/<int:iid>/list", methods=["GET"])
@login_required
def api_client_backup_list(iid: int):
inst = get_instance(iid)
if not inst:
return jsonify({"ok": False, "msg": "实例不存在", "items": []}), 404
result = fetch_backup_list(inst)
code = 200 if result.get("ok") else 502
return jsonify(result), code
@app.route("/api/settings/backup/client/<int:iid>/download/<name>", methods=["GET"])
@login_required
def api_client_backup_download(iid: int, name: str):
inst = get_instance(iid)
if not inst:
return jsonify({"ok": False, "msg": "实例不存在"}), 404
ok, content, meta = fetch_backup_file(inst, name)
if not ok or content is None:
return jsonify({"ok": False, "msg": meta or "下载失败"}), 502
return Response(
content,
mimetype="application/gzip",
headers={
"Content-Disposition": f'attachment; filename="{os.path.basename(meta)}"'
},
)
@app.route("/desk") @app.route("/desk")
@login_required @login_required
def desk(): def desk():
@@ -369,6 +492,16 @@ def start_health_watch() -> None:
start_health_watch() start_health_watch()
try:
start_hub_backup_scheduler(
root_dir=str(ROOT),
env_path=str(ENV_PATH),
db_path_fn=_resolved_hub_db_path,
hour=3,
)
except Exception as _hub_bak_err:
print(f"[backup] scheduler: {_hub_bak_err}")
@app.route("/api/instances/<int:iid>/open_url") @app.route("/api/instances/<int:iid>/open_url")
@login_required @login_required
+228
View File
@@ -0,0 +1,228 @@
"""中控备份/恢复: .env + hub.db;默认落盘 /root/crypto_okx_hub_backups."""
from __future__ import annotations
import json
import os
import re
import shutil
import tarfile
import tempfile
import threading
import time
from datetime import datetime, timedelta, timezone
from typing import Any, Callable
_BJ = timezone(timedelta(hours=8))
_SAFE_NAME_RE = re.compile(r"^crypto_okx_hub_backup_\d{8}_\d{6}\.tar\.gz$")
_LOCK = threading.Lock()
_LAST_AUTO_DAY = ""
def beijing_now() -> datetime:
return datetime.now(_BJ)
def backup_keep_days() -> int:
try:
return max(1, int(os.getenv("BACKUP_KEEP_DAYS") or 10))
except (TypeError, ValueError):
return 10
def backup_dir() -> str:
root = (os.getenv("BACKUP_DIR") or "/root").strip() or "/root"
path = os.path.join(root, "crypto_okx_hub_backups")
os.makedirs(path, exist_ok=True)
return path
def _safe_backup_name(name: str) -> str | None:
n = os.path.basename((name or "").strip())
if not _SAFE_NAME_RE.match(n):
return None
return n
def backup_path(name: str) -> str | None:
safe = _safe_backup_name(name)
if not safe:
return None
path = os.path.abspath(os.path.join(backup_dir(), safe))
root = os.path.abspath(backup_dir())
if not path.startswith(root + os.sep) and path != root:
return None
return path
def list_backups() -> list[dict[str, Any]]:
root = backup_dir()
items: list[dict[str, Any]] = []
try:
names = os.listdir(root)
except OSError:
return []
for name in names:
if not _SAFE_NAME_RE.match(name):
continue
path = os.path.join(root, name)
try:
st = os.stat(path)
except OSError:
continue
items.append(
{
"name": name,
"size": int(st.st_size),
"mtime": int(st.st_mtime),
"mtime_bj": datetime.fromtimestamp(st.st_mtime, _BJ).strftime(
"%Y-%m-%d %H:%M:%S"
),
}
)
items.sort(key=lambda x: x["mtime"], reverse=True)
return items
def prune_backups(*, keep_days: int | None = None) -> int:
days = backup_keep_days() if keep_days is None else max(1, int(keep_days))
cutoff = time.time() - days * 86400
removed = 0
for item in list_backups():
if float(item["mtime"]) >= cutoff:
continue
path = backup_path(item["name"])
if not path:
continue
try:
os.remove(path)
removed += 1
except OSError:
pass
return removed
def create_backup(*, root_dir: str, env_path: str, db_path: str, label: str = "manual") -> dict[str, Any]:
with _LOCK:
now = beijing_now()
name = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d_%H%M%S')}.tar.gz"
out_path = os.path.join(backup_dir(), name)
manifest = {
"kind": "crypto_okx_hub",
"created_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"label": (label or "manual").strip() or "manual",
"includes": [".env", "database"],
}
tmp_fd, tmp_path = tempfile.mkstemp(suffix=".tar.gz", prefix="hub_bak_")
os.close(tmp_fd)
try:
with tarfile.open(tmp_path, "w:gz") as tar:
manifest_path = tmp_path + ".manifest.json"
with open(manifest_path, "w", encoding="utf-8") as f:
json.dump(manifest, f, ensure_ascii=False, indent=2)
try:
tar.add(manifest_path, arcname="manifest.json")
finally:
try:
os.remove(manifest_path)
except OSError:
pass
if env_path and os.path.isfile(env_path):
tar.add(env_path, arcname=".env")
if db_path and os.path.isfile(db_path):
tar.add(db_path, arcname="database/" + os.path.basename(db_path))
shutil.move(tmp_path, out_path)
except Exception:
try:
os.remove(tmp_path)
except OSError:
pass
raise
prune_backups()
st = os.stat(out_path)
return {
"ok": True,
"name": name,
"path": out_path,
"size": int(st.st_size),
"mtime_bj": now.strftime("%Y-%m-%d %H:%M:%S"),
"keep_days": backup_keep_days(),
}
def restore_backup(name: str, *, root_dir: str, env_path: str, db_path: str) -> dict[str, Any]:
path = backup_path(name)
if not path or not os.path.isfile(path):
return {"ok": False, "msg": "备份不存在"}
with _LOCK:
with tempfile.TemporaryDirectory(prefix="hub_restore_") as tmp:
with tarfile.open(path, "r:gz") as tar:
tar.extractall(tmp)
env_src = os.path.join(tmp, ".env")
if os.path.isfile(env_src) and env_path:
shutil.copy2(env_src, env_path)
db_src_dir = os.path.join(tmp, "database")
if os.path.isdir(db_src_dir) and db_path:
for fn in os.listdir(db_src_dir):
src = os.path.join(db_src_dir, fn)
if os.path.isfile(src):
os.makedirs(os.path.dirname(os.path.abspath(db_path)) or ".", exist_ok=True)
shutil.copy2(src, db_path)
break
return {
"ok": True,
"msg": "已恢复 .env / 数据库,建议重启中控进程使配置完全生效",
"name": os.path.basename(path),
}
def maybe_run_auto_backup(
*,
root_dir: str,
env_path: str,
db_path: str,
hour: int = 3,
) -> dict[str, Any] | None:
global _LAST_AUTO_DAY
now = beijing_now()
day = now.strftime("%Y-%m-%d")
if now.hour != int(hour):
return None
if _LAST_AUTO_DAY == day:
return None
prefix = f"crypto_okx_hub_backup_{now.strftime('%Y%m%d')}_"
for item in list_backups():
if str(item["name"]).startswith(prefix):
_LAST_AUTO_DAY = day
return None
result = create_backup(
root_dir=root_dir,
env_path=env_path,
db_path=db_path,
label="auto",
)
_LAST_AUTO_DAY = day
return result
def start_backup_scheduler(
*,
root_dir: str,
env_path: str,
db_path_fn: Callable[[], str],
hour: int = 3,
) -> None:
def loop() -> None:
while True:
try:
maybe_run_auto_backup(
root_dir=root_dir,
env_path=env_path,
db_path=db_path_fn(),
hour=hour,
)
except Exception:
pass
time.sleep(30)
t = threading.Thread(target=loop, name="hub-backup-scheduler", daemon=True)
t.start()
+63
View File
@@ -269,6 +269,69 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]:
return results return results
def fetch_backup_list(inst: dict[str, Any]) -> dict[str, Any]:
base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip()
if not base or not token:
return {"ok": False, "msg": "缺少 URL 或 Token", "items": []}
url = f"{base}/api/hub/backup/list"
try:
r = requests.get(
url,
headers={"X-Hub-Token": token, "Accept": "application/json"},
timeout=_timeout(),
)
data = r.json() if r.content else {}
if isinstance(data, dict):
data.setdefault("instance_id", inst.get("id"))
data.setdefault("instance_name", inst.get("name"))
return data
return {"ok": False, "msg": f"HTTP {r.status_code}", "items": []}
except Exception as e:
return {
"ok": False,
"msg": str(e) or "拉取失败",
"items": [],
"instance_id": inst.get("id"),
"instance_name": inst.get("name"),
}
def fetch_backup_file(inst: dict[str, Any], name: str) -> tuple[bool, bytes | None, str]:
"""返回 (ok, content, msg_or_filename)."""
base = (inst.get("base_url") or "").rstrip("/")
token = (inst.get("bridge_token") or "").strip()
safe = (name or "").strip()
if not base or not token or not safe:
return False, None, "参数不完整"
url = f"{base}/api/hub/backup/download/{safe}"
try:
r = requests.get(
url,
headers={"X-Hub-Token": token, "Accept": "application/gzip, application/json"},
timeout=max(_timeout(), 60.0),
stream=True,
)
ctype = (r.headers.get("content-type") or "").lower()
if r.status_code != 200:
try:
data = r.json()
return False, None, (data.get("msg") if isinstance(data, dict) else None) or f"HTTP {r.status_code}"
except Exception:
return False, None, f"HTTP {r.status_code}"
if "json" in ctype:
try:
data = r.json()
return False, None, (data.get("msg") if isinstance(data, dict) else None) or "下载失败"
except Exception:
return False, None, "下载失败"
return True, r.content, os.path.basename(safe)
except requests.Timeout:
return False, None, "下载超时"
except Exception as e:
return False, None, str(e) or "下载失败"
def dispatch_cond_order_to_ids(instance_ids: list[int], payload: dict[str, Any]) -> list[dict[str, Any]]: def dispatch_cond_order_to_ids(instance_ids: list[int], payload: dict[str, Any]) -> list[dict[str, Any]]:
results = [] results = []
for iid in instance_ids: for iid in instance_ids:
+31
View File
@@ -777,6 +777,37 @@ button, .btn {
gap: 10px; gap: 10px;
margin-top: 2px; margin-top: 2px;
} }
.backup-block-head h3 {
margin: 0 0 4px;
font-size: 1rem;
}
.backup-table {
width: 100%;
border-collapse: collapse;
font-size: 0.82rem;
}
.backup-table th,
.backup-table td {
padding: 8px 10px;
border-bottom: 1px solid rgba(56, 189, 248, 0.12);
text-align: left;
vertical-align: middle;
}
.backup-table th {
color: var(--muted);
font-weight: 550;
}
.backup-table code {
font-size: 0.74rem;
word-break: break-all;
}
.btn-sm {
display: inline-block;
padding: 3px 8px;
font-size: 0.74rem;
border-radius: 8px;
line-height: 1.3;
}
.settings-primary, .settings-primary,
.settings-form button.settings-primary { .settings-form button.settings-primary {
margin-top: 0; margin-top: 0;
+212
View File
@@ -0,0 +1,212 @@
(function (global) {
function escapeHtml(s) {
return String(s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function fmtSize(n) {
const v = Number(n) || 0;
if (v < 1024) return v + " B";
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
return (v / (1024 * 1024)).toFixed(2) + " MB";
}
function setText(id, msg, isErr) {
const el = document.getElementById(id);
if (!el) return;
el.textContent = msg || "";
el.style.color = isErr ? "var(--bad)" : "";
}
function renderRows(tbodyId, items, downloadHrefFn, restoreAttr) {
const body = document.getElementById(tbodyId);
if (!body) return;
if (!items || !items.length) {
body.innerHTML = '<tr><td colspan="4" class="muted">暂无备份</td></tr>';
return;
}
body.innerHTML = items
.map(function (it) {
const name = it.name || "";
const ops =
'<a class="btn btn-sm" href="' +
downloadHrefFn(name) +
'">下载</a>' +
(restoreAttr
? ' <button type="button" class="btn btn-sm btn-secondary" data-restore-name="' +
encodeURIComponent(name) +
'">恢复</button>'
: "");
return (
"<tr>" +
"<td><code>" +
escapeHtml(name) +
"</code></td>" +
"<td>" +
escapeHtml(it.mtime_bj || "—") +
"</td>" +
"<td>" +
escapeHtml(fmtSize(it.size)) +
"</td>" +
"<td style=\"white-space:nowrap\">" +
ops +
"</td>" +
"</tr>"
);
})
.join("");
}
async function loadHubList() {
const body = document.getElementById("hub-backup-body");
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
try {
const r = await fetch("/api/settings/backup/hub/list", { credentials: "same-origin" });
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载失败");
const dir = document.getElementById("hub-backup-dir");
if (dir && j.backup_dir) dir.textContent = j.backup_dir;
renderRows(
"hub-backup-body",
j.items || [],
function (name) {
return "/api/settings/backup/hub/download/" + encodeURIComponent(name);
},
true
);
} catch (e) {
if (body) {
body.innerHTML =
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
}
}
}
async function createHubBackup() {
setText("hub-backup-status", "备份中…");
try {
const r = await fetch("/api/settings/backup/hub/create", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: "{}",
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "备份失败");
setText("hub-backup-status", "已生成 " + (j.name || ""));
await loadHubList();
} catch (e) {
setText("hub-backup-status", e.message || "备份失败", true);
}
}
async function restoreHub(name) {
if (!name) return;
if (!confirm("确定恢复该中控备份?\n将覆盖 .env 与 hub.db,建议恢复后重启。")) return;
setText("hub-backup-status", "恢复中…");
try {
const r = await fetch("/api/settings/backup/hub/restore", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name: name }),
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "恢复失败");
setText("hub-backup-status", j.msg || "恢复完成");
} catch (e) {
setText("hub-backup-status", e.message || "恢复失败", true);
}
}
async function loadInstances() {
const sel = document.getElementById("client-backup-instance");
if (!sel) return;
try {
const r = await fetch("/api/settings/backup/instances", { credentials: "same-origin" });
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载实例失败");
const items = j.items || [];
sel.innerHTML =
'<option value="">选择客户端</option>' +
items
.map(function (it) {
return (
'<option value="' +
escapeHtml(String(it.id)) +
'">' +
escapeHtml(it.name || "#" + it.id) +
(it.enabled ? "" : " (停用)") +
"</option>"
);
})
.join("");
} catch (e) {
setText("client-backup-status", e.message || "加载实例失败", true);
}
}
async function loadClientList() {
const sel = document.getElementById("client-backup-instance");
const body = document.getElementById("client-backup-body");
const iid = sel && sel.value;
if (!iid) {
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">请选择客户端</td></tr>';
return;
}
if (body) body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
setText("client-backup-status", "");
try {
const r = await fetch("/api/settings/backup/client/" + encodeURIComponent(iid) + "/list", {
credentials: "same-origin",
});
const j = await r.json();
if (!j.ok) throw new Error(j.msg || "加载失败");
renderRows(
"client-backup-body",
j.items || [],
function (name) {
return (
"/api/settings/backup/client/" +
encodeURIComponent(iid) +
"/download/" +
encodeURIComponent(name)
);
},
false
);
} catch (e) {
if (body) {
body.innerHTML =
'<tr><td colspan="4" class="muted">' + escapeHtml(e.message || "加载失败") + "</td></tr>";
}
setText("client-backup-status", e.message || "加载失败", true);
}
}
function boot() {
const createBtn = document.getElementById("hub-backup-create");
const hubRefresh = document.getElementById("hub-backup-refresh");
const clientRefresh = document.getElementById("client-backup-refresh");
const sel = document.getElementById("client-backup-instance");
if (createBtn) createBtn.addEventListener("click", createHubBackup);
if (hubRefresh) hubRefresh.addEventListener("click", loadHubList);
if (clientRefresh) clientRefresh.addEventListener("click", loadClientList);
if (sel) sel.addEventListener("change", loadClientList);
const hubBody = document.getElementById("hub-backup-body");
if (hubBody) {
hubBody.addEventListener("click", function (ev) {
const btn = ev.target.closest("[data-restore-name]");
if (!btn) return;
void restoreHub(decodeURIComponent(btn.getAttribute("data-restore-name") || ""));
});
}
void loadHubList();
void loadInstances();
}
global.HubSettingsBackup = { boot: boot };
})(window);
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8"> <meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}期权中控{% endblock %}</title> <title>{% block title %}期权中控{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=4"> <link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=5">
</head> </head>
<body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path == '/' %}page-dashboard{% endif %}"> <body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path == '/' %}page-dashboard{% endif %}">
{% if session.get('logged_in') %} {% if session.get('logged_in') %}
+51
View File
@@ -74,5 +74,56 @@
</div> </div>
</form> </form>
</section> </section>
<section class="panel settings-card settings-card--backup" style="grid-column:1/-1">
<div class="settings-card-head">
<span class="settings-badge">Backup</span>
<h2>备份恢复</h2>
<p class="muted">每天北京时间 03:00 自动备份,保留最近 10 天。中控可下载本机与各客户端备份;客户端仅能下载自己的。</p>
</div>
<div class="backup-block">
<div class="backup-block-head">
<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>
<div class="settings-actions">
<button type="button" class="settings-primary" id="hub-backup-create">立即备份</button>
<button type="button" class="btn-secondary" id="hub-backup-refresh">刷新</button>
<span class="muted" id="hub-backup-status" style="font-size:.8rem"></span>
</div>
</div>
<div class="table-wrap">
<table class="backup-table">
<thead><tr><th>文件</th><th>时间</th><th>大小</th><th>操作</th></tr></thead>
<tbody id="hub-backup-body"><tr><td colspan="4" class="muted">加载中…</td></tr></tbody>
</table>
</div>
</div>
<div class="backup-block" style="margin-top:18px">
<div class="backup-block-head">
<h3>客户端备份</h3>
<p class="muted field-hint">经 Bridge 拉取各机备份列表,可下载到本地</p>
<div class="settings-actions">
<label class="settings-field" style="margin:0;min-width:12rem">
客户端
<select id="client-backup-instance"></select>
</label>
<button type="button" class="btn-secondary" id="client-backup-refresh">刷新</button>
<span class="muted" id="client-backup-status" style="font-size:.8rem"></span>
</div>
</div>
<div class="table-wrap">
<table class="backup-table">
<thead><tr><th>文件</th><th>时间</th><th>大小</th><th>操作</th></tr></thead>
<tbody id="client-backup-body"><tr><td colspan="4" class="muted">请选择客户端</td></tr></tbody>
</table>
</div>
</div>
</section>
</div> </div>
{% endblock %} {% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=1"></script>
<script>HubSettingsBackup.boot();</script>
{% endblock %}