feat: 系统设置增加备份恢复与默认登录 admin
支持手动/每日自动备份四所数据库、K线库与 env,上传 zip 一键恢复;中控默认账号 admin/admin123。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -26,9 +26,9 @@ HUB_TRUST_LAN=true
|
||||
# 云服务器用域名/HTTPS 反代访问中控时设为 true(否则公网可能看到 {"detail":"forbidden"})
|
||||
# HUB_ALLOW_PUBLIC=true
|
||||
|
||||
# 中控 Web 登录(密码非空即启用;反代到公网时务必设置用户名+密码)
|
||||
# HUB_USERNAME=admin
|
||||
# HUB_PASSWORD=your-strong-password-here
|
||||
# 中控 Web 登录(默认 admin / admin123;生产环境请在 .env 中修改)
|
||||
HUB_USERNAME=admin
|
||||
HUB_PASSWORD=admin123
|
||||
# 会话签名密钥(建议单独随机串;未设则用用户名+密码拼接)
|
||||
# HUB_SESSION_SECRET=another-long-random-string
|
||||
# HTTPS 反代时建议 true:仅 HTTPS 访问会带 Secure Cookie;http://内网IP:5100 仍可登录
|
||||
@@ -106,6 +106,7 @@ AI_MODEL=huihui_ai/deepseek-r1-abliterated:latest
|
||||
TRADING_DAY_RESET_HOUR=8
|
||||
# 资金概况 / AI 上下文:分户资金快照保留交易日数(默认 180)
|
||||
# HUB_FUND_HISTORY_DAYS=180
|
||||
# 资金概况:曲线与回撤统计起始交易日(页面说明与曲线均读取此项;该日之前不记、不展示)
|
||||
# 修改后须重启 manual-trading-hub(pm2 restart manual-trading-hub)
|
||||
# 自动备份(系统设置 → 备份与恢复;也可设 HUB_BACKUP_ROOT)
|
||||
# HUB_BACKUP_ROOT=/root/backups/crypto_monitor_portal
|
||||
# 资金概况:曲线与回撤统计起始交易日
|
||||
HUB_FUND_HISTORY_START_DAY=2026-06-09
|
||||
|
||||
+125
-2
@@ -86,7 +86,7 @@ from env_load import load_hub_dotenv
|
||||
load_hub_dotenv()
|
||||
|
||||
import httpx
|
||||
from fastapi import Body, FastAPI, HTTPException, Request
|
||||
from fastapi import Body, FastAPI, File, Form, HTTPException, Request, UploadFile
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -99,6 +99,15 @@ from settings_store import (
|
||||
normalize_supervisor_settings,
|
||||
save_settings,
|
||||
)
|
||||
from lib.hub.hub_backup_lib import (
|
||||
backup_status,
|
||||
normalize_backup_settings,
|
||||
resolve_backup_download,
|
||||
restore_backup_archive,
|
||||
restore_backup_upload,
|
||||
run_backup,
|
||||
should_run_auto_backup,
|
||||
)
|
||||
from hub_web_auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_MAX_AGE_SEC,
|
||||
@@ -157,6 +166,8 @@ _last_archive_sync: dict | None = None
|
||||
_volume_rank_stop: asyncio.Event | None = None
|
||||
_volume_rank_task: asyncio.Task | None = None
|
||||
_volume_rank_cache: dict | None = None
|
||||
_backup_stop: asyncio.Event | None = None
|
||||
_backup_task: asyncio.Task | None = None
|
||||
HUB_AGENT_TIMEOUT = float(os.getenv("HUB_AGENT_TIMEOUT", "8"))
|
||||
HUB_FLASK_TIMEOUT = float(os.getenv("HUB_FLASK_TIMEOUT", "10"))
|
||||
HUB_BOARD_TIMEOUT = float(os.getenv("HUB_BOARD_TIMEOUT", "45"))
|
||||
@@ -518,9 +529,28 @@ async def _run_supervisor_tick() -> dict:
|
||||
)
|
||||
|
||||
|
||||
async def _backup_scheduler_loop() -> None:
|
||||
global _backup_stop
|
||||
stop = _backup_stop
|
||||
if stop is None:
|
||||
return
|
||||
while not stop.is_set():
|
||||
try:
|
||||
settings = load_settings()
|
||||
if should_run_auto_backup(settings):
|
||||
await asyncio.to_thread(run_backup, trigger="auto", settings=settings)
|
||||
except Exception as e:
|
||||
print(f"[backup] auto backup failed: {e}", flush=True)
|
||||
try:
|
||||
await asyncio.wait_for(stop.wait(), timeout=60.0)
|
||||
except asyncio.TimeoutError:
|
||||
pass
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def _hub_lifespan(_app: FastAPI):
|
||||
global _archive_sync_stop, _archive_sync_task, _volume_rank_stop, _volume_rank_task
|
||||
global _backup_stop, _backup_task
|
||||
set_supervisor_notify_hook(supervisor_store.bump)
|
||||
await board_store.start(_run_board_aggregate)
|
||||
await dashboard_store.start(_run_dashboard_aggregate)
|
||||
@@ -530,9 +560,21 @@ async def _hub_lifespan(_app: FastAPI):
|
||||
_archive_sync_task = asyncio.create_task(_archive_sync_loop(), name="hub-archive-sync")
|
||||
_volume_rank_stop = asyncio.Event()
|
||||
_volume_rank_task = asyncio.create_task(_volume_rank_loop(), name="hub-volume-rank")
|
||||
_backup_stop = asyncio.Event()
|
||||
_backup_task = asyncio.create_task(_backup_scheduler_loop(), name="hub-backup-scheduler")
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
if _backup_stop:
|
||||
_backup_stop.set()
|
||||
if _backup_task:
|
||||
_backup_task.cancel()
|
||||
try:
|
||||
await _backup_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
_backup_task = None
|
||||
_backup_stop = None
|
||||
if _archive_sync_stop:
|
||||
_archive_sync_stop.set()
|
||||
if _archive_sync_task:
|
||||
@@ -879,10 +921,20 @@ class SupervisorSettingsBody(BaseModel):
|
||||
reopen_after_close_minutes: int = 30
|
||||
|
||||
|
||||
class BackupSettingsBody(BaseModel):
|
||||
auto_enabled: bool = True
|
||||
auto_hour: int = Field(default=0, ge=0, le=23)
|
||||
retention_days: int = Field(default=30, ge=1, le=365)
|
||||
include_env: bool = True
|
||||
include_exchange_images: bool = False
|
||||
backup_root: str = ""
|
||||
|
||||
|
||||
class SettingsBody(BaseModel):
|
||||
exchanges: list[dict] = Field(default_factory=list)
|
||||
display: SettingsDisplayBody | None = None
|
||||
supervisor: SupervisorSettingsBody | None = None
|
||||
backup: BackupSettingsBody | None = None
|
||||
|
||||
|
||||
@app.post("/api/settings")
|
||||
@@ -903,7 +955,18 @@ def api_save_settings(body: SettingsBody):
|
||||
supervisor = normalize_supervisor_settings(existing.get("supervisor"))
|
||||
if body.supervisor is not None:
|
||||
supervisor = normalize_supervisor_settings(body.supervisor.model_dump())
|
||||
save_settings({"version": 1, "exchanges": to_save, "display": display, "supervisor": supervisor})
|
||||
backup = normalize_backup_settings(existing.get("backup"))
|
||||
if body.backup is not None:
|
||||
backup = normalize_backup_settings(body.backup.model_dump())
|
||||
save_settings(
|
||||
{
|
||||
"version": 1,
|
||||
"exchanges": to_save,
|
||||
"display": display,
|
||||
"supervisor": supervisor,
|
||||
"backup": backup,
|
||||
}
|
||||
)
|
||||
return {"ok": True, "settings": load_settings()}
|
||||
|
||||
|
||||
@@ -1290,6 +1353,65 @@ async def api_chart_poll_meta():
|
||||
return chart_poll_store.event_dict()
|
||||
|
||||
|
||||
@app.get("/api/backup/status")
|
||||
def api_backup_status():
|
||||
return backup_status(load_settings())
|
||||
|
||||
|
||||
@app.post("/api/backup/run")
|
||||
async def api_backup_run():
|
||||
result = await asyncio.to_thread(run_backup, trigger="manual", settings=load_settings())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "backup failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/backup/download/{name}")
|
||||
def api_backup_download(name: str):
|
||||
fp = resolve_backup_download(load_settings(), name)
|
||||
if not fp:
|
||||
raise HTTPException(status_code=404, detail="backup not found")
|
||||
return FileResponse(
|
||||
str(fp),
|
||||
media_type="application/zip",
|
||||
filename=fp.name,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/api/backup/restore")
|
||||
async def api_backup_restore(
|
||||
file: UploadFile = File(...),
|
||||
confirm: str = Form(""),
|
||||
):
|
||||
if (confirm or "").strip().upper() != "RESTORE":
|
||||
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
|
||||
content = await file.read()
|
||||
result = await asyncio.to_thread(
|
||||
restore_backup_upload,
|
||||
content,
|
||||
file.filename or "backup.zip",
|
||||
settings=load_settings(),
|
||||
)
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.post("/api/backup/restore-local")
|
||||
async def api_backup_restore_local(body: dict = Body(...)):
|
||||
confirm = str(body.get("confirm") or "").strip().upper()
|
||||
name = str(body.get("name") or "").strip()
|
||||
if confirm != "RESTORE":
|
||||
raise HTTPException(status_code=400, detail='请在 confirm 字段填写 RESTORE 以确认恢复')
|
||||
fp = resolve_backup_download(load_settings(), name)
|
||||
if not fp:
|
||||
raise HTTPException(status_code=404, detail="backup not found")
|
||||
result = await asyncio.to_thread(restore_backup_archive, fp, settings=load_settings())
|
||||
if not result.get("ok"):
|
||||
raise HTTPException(status_code=500, detail=result.get("error") or "restore failed")
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/settings/meta")
|
||||
def api_settings_meta():
|
||||
po = public_origin()
|
||||
@@ -1304,6 +1426,7 @@ def api_settings_meta():
|
||||
else "复盘/展示链接已替换为对外地址"
|
||||
),
|
||||
"password_required": password_required(),
|
||||
"default_username": expected_username(),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from secrets import compare_digest
|
||||
SESSION_COOKIE = "hub_sess"
|
||||
SESSION_MAX_AGE_SEC = max(3600, int(os.getenv("HUB_SESSION_DAYS", "7")) * 86400)
|
||||
DEFAULT_USERNAME = "admin"
|
||||
DEFAULT_PASSWORD = "admin123"
|
||||
|
||||
|
||||
def _env_username() -> str:
|
||||
@@ -20,12 +21,13 @@ def _env_username() -> str:
|
||||
|
||||
|
||||
def _env_password() -> str:
|
||||
return (os.getenv("HUB_PASSWORD") or "").strip()
|
||||
raw = (os.getenv("HUB_PASSWORD") or "").strip()
|
||||
return raw or DEFAULT_PASSWORD
|
||||
|
||||
|
||||
def password_required() -> bool:
|
||||
"""已配置密码即要求登录(用户名未设时默认 admin)。"""
|
||||
return bool(_env_password())
|
||||
"""默认启用登录(admin / admin123,可通过 .env 覆盖)。"""
|
||||
return True
|
||||
|
||||
|
||||
def expected_username() -> str:
|
||||
@@ -33,8 +35,6 @@ def expected_username() -> str:
|
||||
|
||||
|
||||
def verify_credentials(username: str, password: str) -> bool:
|
||||
if not _env_password():
|
||||
return True
|
||||
u_ok = compare_digest(expected_username(), (username or "").strip())
|
||||
p_ok = compare_digest(_env_password(), (password or "").strip())
|
||||
return u_ok and p_ok
|
||||
|
||||
@@ -8,13 +8,17 @@ from pathlib import Path
|
||||
|
||||
DIR = Path(__file__).resolve().parent
|
||||
SETTINGS_PATH = DIR / "hub_settings.json"
|
||||
_REPO_ROOT = DIR.parent
|
||||
|
||||
import sys
|
||||
|
||||
if str(_REPO_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(_REPO_ROOT))
|
||||
if str(DIR) not in sys.path:
|
||||
sys.path.insert(0, str(DIR))
|
||||
|
||||
from hub_supervisor_lib import DEFAULT_SUPERVISOR, normalize_supervisor_settings
|
||||
from lib.hub.hub_backup_lib import normalize_backup_settings
|
||||
|
||||
DEFAULT_DISPLAY = {
|
||||
"show_account_pnl": True,
|
||||
@@ -106,6 +110,7 @@ def load_settings() -> dict:
|
||||
pass
|
||||
data["display"] = normalize_display_prefs(data.get("display"))
|
||||
data["supervisor"] = normalize_supervisor_settings(data.get("supervisor"))
|
||||
data["backup"] = normalize_backup_settings(data.get("backup"))
|
||||
force_off = env_force_disabled_ids()
|
||||
for ex in data.get("exchanges") or []:
|
||||
if str(ex.get("id")) in force_off:
|
||||
@@ -120,6 +125,7 @@ def save_settings(data: dict) -> None:
|
||||
payload = dict(data)
|
||||
payload["display"] = normalize_display_prefs(payload.get("display"))
|
||||
payload["supervisor"] = normalize_supervisor_settings(payload.get("supervisor"))
|
||||
payload["backup"] = normalize_backup_settings(payload.get("backup"))
|
||||
SETTINGS_PATH.write_text(
|
||||
json.dumps(payload, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
|
||||
@@ -2904,6 +2904,88 @@ button.btn-sm {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.backup-settings-grid {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.backup-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.backup-status-line {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.backup-status-line.err {
|
||||
color: var(--danger, #f87171);
|
||||
}
|
||||
|
||||
.backup-restore-upload {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.backup-upload-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.backup-list {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.backup-meta {
|
||||
font-size: 0.78rem;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.backup-meta code {
|
||||
font-size: 0.76rem;
|
||||
}
|
||||
|
||||
.backup-empty {
|
||||
font-size: 0.82rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.backup-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.backup-table th,
|
||||
.backup-table td {
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.backup-row-actions {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.backup-row-actions .ghost,
|
||||
.backup-row-actions .danger {
|
||||
font-size: 0.78rem;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
|
||||
@@ -3969,6 +3969,7 @@
|
||||
syncSupervisorSettingsUI(data);
|
||||
renderSettingsList(data);
|
||||
initSettingsSectionFolds();
|
||||
if (typeof initBackupSettingsUI === "function") void initBackupSettingsUI();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* 系统设置 · 备份与恢复
|
||||
*/
|
||||
(function () {
|
||||
const page = document.getElementById("page-settings");
|
||||
if (!page) return;
|
||||
|
||||
const elAuto = document.getElementById("backup-auto-enabled");
|
||||
const elHour = document.getElementById("backup-auto-hour");
|
||||
const elRetention = document.getElementById("backup-retention-days");
|
||||
const elIncludeEnv = document.getElementById("backup-include-env");
|
||||
const elIncludeImages = document.getElementById("backup-include-images");
|
||||
const elRoot = document.getElementById("backup-root");
|
||||
const elStatus = document.getElementById("backup-status-line");
|
||||
const elList = document.getElementById("backup-list");
|
||||
const elRun = document.getElementById("backup-run-now");
|
||||
const elRestoreFile = document.getElementById("backup-restore-file");
|
||||
const elRestoreBtn = document.getElementById("backup-restore-upload-btn");
|
||||
|
||||
let settingsCache = null;
|
||||
let statusCache = null;
|
||||
|
||||
function fmtBytes(n) {
|
||||
const v = Number(n);
|
||||
if (!Number.isFinite(v) || v < 0) return "—";
|
||||
if (v < 1024) return v + " B";
|
||||
if (v < 1024 * 1024) return (v / 1024).toFixed(1) + " KB";
|
||||
return (v / (1024 * 1024)).toFixed(2) + " MB";
|
||||
}
|
||||
|
||||
function setStatus(msg, isErr) {
|
||||
if (!elStatus) return;
|
||||
elStatus.textContent = msg || "";
|
||||
elStatus.className = "backup-status-line" + (isErr ? " err" : "");
|
||||
}
|
||||
|
||||
function collectBackupFromUI() {
|
||||
return {
|
||||
auto_enabled: !!(elAuto && elAuto.checked),
|
||||
auto_hour: Math.max(0, Math.min(23, parseInt(elHour && elHour.value, 10) || 0)),
|
||||
retention_days: Math.max(1, Math.min(365, parseInt(elRetention && elRetention.value, 10) || 30)),
|
||||
include_env: !!(elIncludeEnv && elIncludeEnv.checked),
|
||||
include_exchange_images: !!(elIncludeImages && elIncludeImages.checked),
|
||||
backup_root: (elRoot && elRoot.value || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function syncBackupUI(data) {
|
||||
const b = (data && data.backup) || {};
|
||||
if (elAuto) elAuto.checked = b.auto_enabled !== false;
|
||||
if (elHour) elHour.value = b.auto_hour != null ? b.auto_hour : 0;
|
||||
if (elRetention) elRetention.value = b.retention_days != null ? b.retention_days : 30;
|
||||
if (elIncludeEnv) elIncludeEnv.checked = b.include_env !== false;
|
||||
if (elIncludeImages) elIncludeImages.checked = !!b.include_exchange_images;
|
||||
if (elRoot) elRoot.value = b.backup_root || "";
|
||||
}
|
||||
|
||||
function renderBackupList(status) {
|
||||
if (!elList) return;
|
||||
const rows = (status && status.backups) || [];
|
||||
const state = (status && status.state) || {};
|
||||
const root = (status && status.backup_root) || "";
|
||||
let html = '<div class="backup-meta">';
|
||||
html += '<div>目录:<code>' + esc(root) + '</code></div>';
|
||||
if (state.last_backup_at) {
|
||||
html += '<div>上次备份:' + esc(state.last_backup_at) + '(' + esc(state.last_trigger || "") + ")</div>";
|
||||
}
|
||||
if (state.last_auto_at) {
|
||||
html += '<div>上次自动:' + esc(state.last_auto_at) + "</div>";
|
||||
}
|
||||
if (state.last_restore_at) {
|
||||
html += '<div>上次恢复:' + esc(state.last_restore_at) + " ← " + esc(state.last_restore_from || "") + "</div>";
|
||||
}
|
||||
html += "</div>";
|
||||
if (!rows.length) {
|
||||
html += '<p class="backup-empty">暂无备份文件</p>';
|
||||
elList.innerHTML = html;
|
||||
return;
|
||||
}
|
||||
html += '<table class="backup-table"><thead><tr><th>文件</th><th>大小</th><th>时间</th><th></th></tr></thead><tbody>';
|
||||
rows.forEach(function (row) {
|
||||
html +=
|
||||
"<tr><td>" +
|
||||
esc(row.name) +
|
||||
"</td><td>" +
|
||||
fmtBytes(row.size) +
|
||||
"</td><td>" +
|
||||
esc(row.modified_at || "") +
|
||||
'</td><td class="backup-row-actions">' +
|
||||
'<a class="ghost" href="/api/backup/download/' +
|
||||
encodeURIComponent(row.name) +
|
||||
'" download>下载</a> ' +
|
||||
'<button type="button" class="danger backup-restore-local" data-name="' +
|
||||
escAttr(row.name) +
|
||||
'">恢复</button></td></tr>';
|
||||
});
|
||||
html += "</tbody></table>";
|
||||
elList.innerHTML = html;
|
||||
elList.querySelectorAll(".backup-restore-local").forEach(function (btn) {
|
||||
btn.addEventListener("click", function () {
|
||||
restoreLocal(btn.getAttribute("data-name"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s || "")
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escAttr(s) {
|
||||
return esc(s).replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function loadSettingsData() {
|
||||
const r = await fetch("/api/settings", { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error("加载设置失败");
|
||||
settingsCache = await r.json();
|
||||
syncBackupUI(settingsCache);
|
||||
}
|
||||
|
||||
async function loadBackupStatus() {
|
||||
const r = await fetch("/api/backup/status", { credentials: "same-origin" });
|
||||
if (!r.ok) throw new Error("加载备份状态失败");
|
||||
statusCache = await r.json();
|
||||
renderBackupList(statusCache);
|
||||
}
|
||||
|
||||
async function saveBackupSettings() {
|
||||
if (!settingsCache) await loadSettingsData();
|
||||
const body = {
|
||||
exchanges: settingsCache.exchanges || [],
|
||||
display: settingsCache.display,
|
||||
supervisor: settingsCache.supervisor,
|
||||
backup: collectBackupFromUI(),
|
||||
};
|
||||
const r = await fetch("/api/settings", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!r.ok) throw new Error("保存失败");
|
||||
settingsCache = (await r.json()).settings || settingsCache;
|
||||
syncBackupUI(settingsCache);
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("备份设置已保存");
|
||||
}
|
||||
|
||||
async function runBackupNow() {
|
||||
setStatus("备份中…");
|
||||
const r = await fetch("/api/backup/run", { method: "POST", credentials: "same-origin" });
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "备份失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("完成:" + (data.file || "") + "(" + fmtBytes(data.size) + ")");
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("备份完成");
|
||||
}
|
||||
|
||||
async function restoreLocal(name) {
|
||||
if (!name) return;
|
||||
if (!window.confirm("确认从服务器备份 " + name + " 恢复?\n恢复前会自动做 pre-restore 快照并重启 PM2。")) return;
|
||||
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
|
||||
setStatus("恢复中…");
|
||||
const r = await fetch("/api/backup/restore-local", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: name, confirm: "RESTORE" }),
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "恢复失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
|
||||
}
|
||||
|
||||
async function restoreUpload() {
|
||||
const file = elRestoreFile && elRestoreFile.files && elRestoreFile.files[0];
|
||||
if (!file) {
|
||||
setStatus("请选择 .zip 备份文件", true);
|
||||
return;
|
||||
}
|
||||
if (!window.confirm("确认上传并恢复 " + file.name + "?\n恢复前会自动做 pre-restore 快照并重启 PM2。")) return;
|
||||
if (window.prompt('请输入 RESTORE 确认恢复') !== "RESTORE") return;
|
||||
setStatus("上传并恢复中…");
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
fd.append("confirm", "RESTORE");
|
||||
const r = await fetch("/api/backup/restore", {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
body: fd,
|
||||
});
|
||||
const data = await r.json().catch(function () {
|
||||
return {};
|
||||
});
|
||||
if (!r.ok) {
|
||||
setStatus(data.detail || "恢复失败", true);
|
||||
return;
|
||||
}
|
||||
setStatus("恢复完成,已恢复 " + ((data.restored && data.restored.length) || 0) + " 个文件");
|
||||
if (elRestoreFile) elRestoreFile.value = "";
|
||||
await loadBackupStatus();
|
||||
if (typeof showToast === "function") showToast("恢复完成,请刷新页面");
|
||||
}
|
||||
|
||||
window.initBackupSettingsUI = async function () {
|
||||
try {
|
||||
await loadSettingsData();
|
||||
await loadBackupStatus();
|
||||
setStatus("");
|
||||
} catch (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
}
|
||||
};
|
||||
|
||||
if (elRun) elRun.addEventListener("click", function () {
|
||||
runBackupNow().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
|
||||
if (elRestoreBtn) elRestoreBtn.addEventListener("click", function () {
|
||||
restoreUpload().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
|
||||
page.addEventListener("click", function (ev) {
|
||||
const btn = ev.target.closest(".settings-section-save[data-settings-section='backup']");
|
||||
if (!btn) return;
|
||||
ev.preventDefault();
|
||||
saveBackupSettings().catch(function (e) {
|
||||
setStatus(e.message || String(e), true);
|
||||
});
|
||||
});
|
||||
})();
|
||||
@@ -862,7 +862,7 @@
|
||||
<div class="hint-body">
|
||||
保存后写入 <code>hub_settings.json</code>。Flask / Agent 填本机地址即可;复盘链接可留空(由 Flask 地址自动生成)。<br />
|
||||
<code>HUB_DISABLED_IDS</code> 可强制关闭账户;<code>HUB_BRIDGE_TOKEN</code> 与实例一致,或实例 <code>APP_AUTH_DISABLED=true</code>。<br />
|
||||
公网反代请在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code> 与 <code>HUB_PASSWORD</code>;HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code>。
|
||||
公网反代请在 hub <code>.env</code> 设置 <code>HUB_USERNAME</code> 与 <code>HUB_PASSWORD</code>(默认 <code>admin</code> / <code>admin123</code>);HTTPS 反代建议 <code>HUB_COOKIE_SECURE=true</code>。
|
||||
</div>
|
||||
</details>
|
||||
<p id="settings-meta-line" class="settings-meta-line"></p>
|
||||
@@ -989,6 +989,57 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section card settings-backup-panel" data-settings-section="backup">
|
||||
<div class="settings-section-head">
|
||||
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
|
||||
<h3 class="settings-display-title">备份与恢复</h3>
|
||||
<button type="button" class="primary settings-section-save" data-settings-section="backup">保存</button>
|
||||
</div>
|
||||
<div class="settings-section-body">
|
||||
<p class="settings-display-hint">
|
||||
打包四所 <code>crypto.db</code>、中控 K 线/归档等 SQLite、<code>hub_settings.json</code> 与 <code>.env</code>(可选)。
|
||||
恢复前会自动做一次 pre-restore 快照,并尝试 <code>pm2 restart all</code>。
|
||||
</p>
|
||||
<div class="settings-grid backup-settings-grid">
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="backup-auto-enabled" checked />
|
||||
每日自动备份(北京时间)
|
||||
</label>
|
||||
<div class="field">
|
||||
<label>自动备份时刻(时,0–23)</label>
|
||||
<input id="backup-auto-hour" type="number" min="0" max="23" step="1" value="0" />
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>保留天数</label>
|
||||
<input id="backup-retention-days" type="number" min="1" max="365" step="1" value="30" />
|
||||
</div>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="backup-include-env" checked />
|
||||
包含 .env 配置文件
|
||||
</label>
|
||||
<label class="chk-label settings-display-chk">
|
||||
<input type="checkbox" id="backup-include-images" />
|
||||
包含四所 static/images 截图
|
||||
</label>
|
||||
<div class="field field-wide">
|
||||
<label>备份目录(留空默认 /root/backups/crypto_monitor_portal)</label>
|
||||
<input id="backup-root" type="text" placeholder="/root/backups/crypto_monitor_portal" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="backup-actions">
|
||||
<button type="button" id="backup-run-now" class="primary">立即备份</button>
|
||||
<span id="backup-status-line" class="backup-status-line"></span>
|
||||
</div>
|
||||
<div class="backup-restore-upload">
|
||||
<label class="backup-upload-label">
|
||||
<span>上传备份包恢复(.zip)</span>
|
||||
<input id="backup-restore-file" type="file" accept=".zip,application/zip" />
|
||||
</label>
|
||||
<button type="button" id="backup-restore-upload-btn" class="danger">上传并恢复</button>
|
||||
</div>
|
||||
<div id="backup-list" class="backup-list"></div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="settings-section card" data-settings-section="exchanges">
|
||||
<div class="settings-section-head">
|
||||
<button type="button" class="settings-section-fold" aria-expanded="true" aria-label="折叠"></button>
|
||||
@@ -1064,6 +1115,7 @@
|
||||
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
<script src="/assets/time_close_ui.js?v=2"></script>
|
||||
<script src="/assets/backup.js?v=1"></script>
|
||||
<script src="/assets/app.js?v=20260614-instance-nav"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
<p id="login-err" class="login-err" hidden></p>
|
||||
<p id="login-hint" class="login-foot" hidden></p>
|
||||
</form>
|
||||
<p class="login-foot">账号在云端 hub 的 <code>.env</code>:<code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
|
||||
<p class="login-foot">默认账号 <code>admin</code> / <code>admin123</code>;可在 hub <code>.env</code> 修改 <code>HUB_USERNAME</code> / <code>HUB_PASSWORD</code></p>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
|
||||
Reference in New Issue
Block a user