diff --git a/modules/backup/db_backup.py b/modules/backup/db_backup.py index 9fa4397..f0d0f7b 100644 --- a/modules/backup/db_backup.py +++ b/modules/backup/db_backup.py @@ -48,6 +48,7 @@ RESTORE_MD = """# qihuo 备份恢复说明 | `futures.db` | SQLite 主库(仅 SQLite 模式备份) | | `postgres_dump.sql` | PostgreSQL 逻辑备份(仅 PostgreSQL 模式) | | `uploads/` | 复盘截图与 K 线图(若备份时存在) | +| `.env` | 环境配置(`config/.env` 或根目录 `.env`) | | `manifest.json` | 备份元数据(含 `backend` 字段) | | `restore.sh` | 一键恢复脚本 | @@ -73,7 +74,7 @@ RESTORE_DIR=/opt/qihuo ./restore.sh ``` 3. 在新服务器部署 qihuo 代码与 Python 环境(见 `docs/POSTGRES.md` / `docs/DEPLOY.md`) -4. 配置 `.env`(`DATABASE_URL` 或 SQLite、`SECRET_KEY`、CTP 账号等) +4. 若包内含 `.env`,`restore.sh` 会自动恢复到应用目录(无需手工复制) 5. 重启服务:`pm2 restart qihuo` ## PostgreSQL 恢复 @@ -101,7 +102,7 @@ cp -a uploads/. /opt/qihuo/uploads/ ## 注意 - 恢复前请停止 qihuo 进程 -- `.env` 含敏感信息,请单独安全传输 +- `.env` 含敏感信息,请妥善保管备份包 - 详见 `docs/POSTGRES.md` 与 `docs/BACKUP.md` """ @@ -176,7 +177,25 @@ def _backup_postgres(dst_path: str) -> None: raise RuntimeError(f"pg_dump 失败: {proc.stderr.strip() or proc.stdout.strip()}") -def _write_restore_script(dest: Path, *, backend: str) -> None: +def _env_backup_info() -> tuple[Optional[Path], str]: + """返回 (源 .env 路径, 恢复到应用目录的相对路径)。""" + from modules.core.paths import CONFIG_DIR, LEGACY_ENV_FILE, ROOT, resolve_env_file + + src = Path(resolve_env_file()) + if not src.is_file(): + return None, "config/.env" + try: + if src.resolve().parent == CONFIG_DIR.resolve(): + return src, "config/.env" + if src.resolve().parent == ROOT.resolve() and src.name == ".env": + if LEGACY_ENV_FILE.is_file() and not (CONFIG_DIR / ".env").is_file(): + return src, ".env" + except Exception: + pass + return src, "config/.env" + + +def _write_restore_script(dest: Path, *, backend: str, env_restore_path: str = "") -> None: pg_block = "" if backend == "postgres": pg_block = """ @@ -201,13 +220,23 @@ if [ -f "$SCRIPT_DIR/postgres_dump.sql" ]; then psql "$DATABASE_URL" -f "$SCRIPT_DIR/postgres_dump.sql" echo "PostgreSQL 导入完成" fi +""" + env_block = "" + if env_restore_path: + env_block = f""" +if [ -f "$SCRIPT_DIR/.env" ]; then + ENV_DEST="$RESTORE_DIR/{env_restore_path}" + mkdir -p "$(dirname "$ENV_DEST")" + cp -f "$SCRIPT_DIR/.env" "$ENV_DEST" + echo "已复制 .env -> $ENV_DEST" +fi """ script = f"""#!/bin/bash set -euo pipefail RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" mkdir -p "$RESTORE_DIR/uploads" -{pg_block} +{pg_block}{env_block} if [ -f "$SCRIPT_DIR/futures.db" ]; then cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db" echo "已复制 futures.db -> $RESTORE_DIR/futures.db" @@ -218,7 +247,7 @@ if [ -d "$SCRIPT_DIR/uploads" ]; then fi echo "" echo "恢复完成。目标目录: $RESTORE_DIR" -echo "下一步: 确认 .env、pm2 restart qihuo" +echo "下一步: pm2 restart qihuo" echo "详见 RESTORE.md 与 docs/POSTGRES.md" """ dest.write_text(script, encoding="utf-8") @@ -251,12 +280,20 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]: if include_uploads and upload_src.is_dir(): shutil.copytree(upload_src, work / "uploads", dirs_exist_ok=True) + env_src, env_restore_path = _env_backup_info() + includes_env = False + if env_src and env_src.is_file(): + shutil.copy2(env_src, work / ".env") + includes_env = True + manifest = { "app": "qihuo", "backend": backend, "created_at": datetime.now(TZ).isoformat(timespec="seconds"), "db_path": DB_PATH if backend == "sqlite" else (os.getenv("DATABASE_URL") or ""), "includes_uploads": include_uploads and upload_src.is_dir(), + "includes_env": includes_env, + "env_restore_path": env_restore_path if includes_env else "", "default_restore_dir": default_restore_dir(), "files": sorted(p.name for p in work.iterdir()), } @@ -265,7 +302,11 @@ def create_backup(*, include_uploads: bool = True) -> tuple[str, str]: encoding="utf-8", ) (work / "RESTORE.md").write_text(RESTORE_MD, encoding="utf-8") - _write_restore_script(work / "restore.sh", backend=backend) + _write_restore_script( + work / "restore.sh", + backend=backend, + env_restore_path=env_restore_path if includes_env else "", + ) with tarfile.open(out_path, "w:gz") as tar: tar.add(work, arcname=folder_name) diff --git a/modules/web/static/css/dashboard.css b/modules/web/static/css/dashboard.css index 70748c7..f78a149 100644 --- a/modules/web/static/css/dashboard.css +++ b/modules/web/static/css/dashboard.css @@ -25,6 +25,48 @@ font-size: 0.78rem; } +.dash-server-compact { + display: inline-flex; + align-items: center; + flex-wrap: wrap; + gap: 0.35rem 0.45rem; + max-width: 100%; + padding: 0.28rem 0.55rem; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 999px; + background: rgba(0, 0, 0, 0.22); + color: var(--text-title); + font-size: 0.76rem; + line-height: 1.35; + cursor: pointer; + text-align: left; +} + +.dash-server-compact:hover { + border-color: rgba(76, 217, 127, 0.35); +} + +.dash-server-compact[aria-expanded="true"] .dash-toggle-icon { + transform: rotate(180deg); +} + +.dash-server-compact-label { + font-weight: 600; + white-space: nowrap; +} + +.dash-server-summary { + font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + font-size: 0.72rem; + word-break: break-all; +} + +.dash-server-compact .dash-toggle-icon { + font-size: 0.62rem; + opacity: 0.75; + transition: transform 0.2s ease; +} + .dashboard-account-card { margin-bottom: 0; } @@ -46,6 +88,10 @@ margin-bottom: 0; } +.dashboard-server-card[hidden] { + display: none !important; +} + .dash-server-head { display: flex; align-items: flex-start; diff --git a/modules/web/static/js/dashboard.js b/modules/web/static/js/dashboard.js index 19a4bd4..4060b02 100644 --- a/modules/web/static/js/dashboard.js +++ b/modules/web/static/js/dashboard.js @@ -41,6 +41,9 @@ var serverDiskSubEl = document.getElementById('dash-server-disk-sub'); var serverNetUpEl = document.getElementById('dash-server-net-up'); var serverNetDownEl = document.getElementById('dash-server-net-down'); + var serverCardEl = document.getElementById('dash-server-card'); + var serverToggleEl = document.getElementById('dash-server-toggle'); + var serverSummaryEl = document.getElementById('dash-server-summary'); var pollTimer = null; var pollInFlight = false; @@ -78,6 +81,30 @@ riskToggleEl.setAttribute('aria-expanded', expanded ? 'true' : 'false'); } + function initServerToggle() { + if (!serverCardEl || !serverToggleEl) return; + serverCardEl.hidden = true; + serverToggleEl.setAttribute('aria-expanded', 'false'); + function toggleServer() { + var expanded = serverCardEl.hidden; + serverCardEl.hidden = !expanded; + serverToggleEl.setAttribute('aria-expanded', expanded ? 'true' : 'false'); + } + serverToggleEl.addEventListener('click', toggleServer); + } + + function buildServerSummary(server) { + if (!server) return '—'; + var parts = []; + if (server.hostname) parts.push(server.hostname); + if (server.public_ip) parts.push('外网 ' + server.public_ip); + if (server.private_ip) parts.push('内网 ' + server.private_ip); + if (server.cpu_pct != null) parts.push('CPU ' + server.cpu_pct + '%'); + if (server.memory_pct != null) parts.push('内存 ' + server.memory_pct + '%'); + if (server.disk_pct != null) parts.push('硬盘 ' + server.disk_pct + '%'); + return parts.length ? parts.join(' · ') : '—'; + } + function initRiskToggle() { if (!riskCardEl || !riskToggleEl) return; if (shouldCollapseRiskDefault()) { @@ -524,6 +551,7 @@ function applyServer(server) { if (!server) return; + if (serverSummaryEl) serverSummaryEl.textContent = buildServerSummary(server); if (serverHostEl) serverHostEl.textContent = server.hostname || '—'; if (serverUptimeEl) { serverUptimeEl.textContent = server.uptime_label @@ -1102,6 +1130,7 @@ startPolling(); connectPositionStream(); + initServerToggle(); initRiskToggle(); initDetailModal(); initMobileLists(); diff --git a/modules/web/templates/dashboard.html b/modules/web/templates/dashboard.html index 55c5866..a12d8bb 100644 --- a/modules/web/templates/dashboard.html +++ b/modules/web/templates/dashboard.html @@ -11,33 +11,20 @@
备份含 futures.db、uploads/,默认恢复至 {{ backup_restore_dir }}。
备份含 futures.db、uploads/、.env,默认恢复至 {{ backup_restore_dir }}。