Collapse dashboard server status into top bar and include .env in backup restore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-02 15:43:48 +08:00
parent aae897b7eb
commit 481086eddc
5 changed files with 149 additions and 29 deletions
+47 -6
View File
@@ -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)
+46
View File
@@ -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;
+29
View File
@@ -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();
+25 -21
View File
@@ -11,33 +11,20 @@
<div class="dashboard-top-left">
<span class="badge planned" id="dash-mode-badge"></span>
<span class="badge planned" id="dash-ctp-badge">CTP 检测中…</span>
<button type="button" class="dash-server-compact" id="dash-server-toggle" aria-expanded="false" aria-controls="dash-server-card">
<span class="dash-server-dot" aria-hidden="true"></span>
<span class="dash-server-compact-label">服务器状态</span>
<span class="dash-server-summary text-muted" id="dash-server-summary"></span>
<span class="dash-toggle-icon" aria-hidden="true"></span>
</button>
<span class="text-muted dash-updated" id="dash-updated">正在加载…</span>
</div>
</div>
<div class="card dashboard-account-card">
<div class="stat-grid stat-grid-summary dashboard-account-grid">
<div class="stat-item">
<div class="label">账户权益</div>
<div class="value" id="dash-equity"></div>
</div>
<div class="stat-item">
<div class="label">占用保证金</div>
<div class="value" id="dash-margin"></div>
</div>
<div class="stat-item">
<div class="label">可用权益</div>
<div class="value" id="dash-available"></div>
</div>
</div>
</div>
<div class="card dashboard-section dashboard-server-card" id="dash-server-card">
<div class="card dashboard-section dashboard-server-card" id="dash-server-card" hidden>
<div class="dash-server-head">
<div class="dash-server-title">
<span class="dash-server-dot" aria-hidden="true"></span>
<span>服务器状态</span>
<span class="dash-server-host text-muted" id="dash-server-host"></span>
<span class="dash-server-host" id="dash-server-host"></span>
</div>
<div class="dash-server-head-meta text-muted">
<span id="dash-server-uptime"></span>
@@ -92,6 +79,23 @@
</div>
</div>
<div class="card dashboard-account-card">
<div class="stat-grid stat-grid-summary dashboard-account-grid">
<div class="stat-item">
<div class="label">账户权益</div>
<div class="value" id="dash-equity"></div>
</div>
<div class="stat-item">
<div class="label">占用保证金</div>
<div class="value" id="dash-margin"></div>
</div>
<div class="stat-item">
<div class="label">可用权益</div>
<div class="value" id="dash-available"></div>
</div>
</div>
</div>
<div class="card dashboard-section dashboard-risk-card" id="dash-risk-card">
<h2 class="dashboard-risk-heading dash-section-toggle" id="dash-risk-toggle" role="button" tabindex="0" aria-expanded="false" aria-controls="dash-risk-body">
<span class="dash-section-toggle-label">风控说明</span>
+2 -2
View File
@@ -535,7 +535,7 @@
<button type="submit" class="btn-primary" {% if backup_running %}disabled{% endif %}>立即备份</button>
</form>
</div>
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code><code>uploads/</code>,默认恢复至 <code>{{ backup_restore_dir }}</code></p>
<p class="hint" style="margin:.5rem 0 0">备份含 <code>futures.db</code><code>uploads/</code><code>.env</code>,默认恢复至 <code>{{ backup_restore_dir }}</code></p>
{% if backup_items %}
<table class="settings-backup-table">
@@ -564,7 +564,7 @@
<li>解压:<code>tar -xzf qihuo_backup_*.tar.gz</code></li>
<li>执行:<code>chmod +x restore.sh &amp;&amp; ./restore.sh</code></li>
<li>指定目录:<code>RESTORE_DIR=/opt/qihuo ./restore.sh</code></li>
<li>部署代码、配置 <code>.env</code> 后重启服务。</li>
<li>恢复脚本会自动还原数据库、<code>uploads/</code> <code>.env</code>,然后重启服务。</li>
</ol>
</details>
{% endcall %}