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:
@@ -48,6 +48,7 @@ RESTORE_MD = """# qihuo 备份恢复说明
|
|||||||
| `futures.db` | SQLite 主库(仅 SQLite 模式备份) |
|
| `futures.db` | SQLite 主库(仅 SQLite 模式备份) |
|
||||||
| `postgres_dump.sql` | PostgreSQL 逻辑备份(仅 PostgreSQL 模式) |
|
| `postgres_dump.sql` | PostgreSQL 逻辑备份(仅 PostgreSQL 模式) |
|
||||||
| `uploads/` | 复盘截图与 K 线图(若备份时存在) |
|
| `uploads/` | 复盘截图与 K 线图(若备份时存在) |
|
||||||
|
| `.env` | 环境配置(`config/.env` 或根目录 `.env`) |
|
||||||
| `manifest.json` | 备份元数据(含 `backend` 字段) |
|
| `manifest.json` | 备份元数据(含 `backend` 字段) |
|
||||||
| `restore.sh` | 一键恢复脚本 |
|
| `restore.sh` | 一键恢复脚本 |
|
||||||
|
|
||||||
@@ -73,7 +74,7 @@ RESTORE_DIR=/opt/qihuo ./restore.sh
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. 在新服务器部署 qihuo 代码与 Python 环境(见 `docs/POSTGRES.md` / `docs/DEPLOY.md`)
|
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`
|
5. 重启服务:`pm2 restart qihuo`
|
||||||
|
|
||||||
## PostgreSQL 恢复
|
## PostgreSQL 恢复
|
||||||
@@ -101,7 +102,7 @@ cp -a uploads/. /opt/qihuo/uploads/
|
|||||||
## 注意
|
## 注意
|
||||||
|
|
||||||
- 恢复前请停止 qihuo 进程
|
- 恢复前请停止 qihuo 进程
|
||||||
- `.env` 含敏感信息,请单独安全传输
|
- `.env` 含敏感信息,请妥善保管备份包
|
||||||
- 详见 `docs/POSTGRES.md` 与 `docs/BACKUP.md`
|
- 详见 `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()}")
|
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 = ""
|
pg_block = ""
|
||||||
if backend == "postgres":
|
if backend == "postgres":
|
||||||
pg_block = """
|
pg_block = """
|
||||||
@@ -201,13 +220,23 @@ if [ -f "$SCRIPT_DIR/postgres_dump.sql" ]; then
|
|||||||
psql "$DATABASE_URL" -f "$SCRIPT_DIR/postgres_dump.sql"
|
psql "$DATABASE_URL" -f "$SCRIPT_DIR/postgres_dump.sql"
|
||||||
echo "PostgreSQL 导入完成"
|
echo "PostgreSQL 导入完成"
|
||||||
fi
|
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
|
script = f"""#!/bin/bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}"
|
RESTORE_DIR="${{RESTORE_DIR:-{default_restore_dir()}}}"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
mkdir -p "$RESTORE_DIR/uploads"
|
mkdir -p "$RESTORE_DIR/uploads"
|
||||||
{pg_block}
|
{pg_block}{env_block}
|
||||||
if [ -f "$SCRIPT_DIR/futures.db" ]; then
|
if [ -f "$SCRIPT_DIR/futures.db" ]; then
|
||||||
cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db"
|
cp -f "$SCRIPT_DIR/futures.db" "$RESTORE_DIR/futures.db"
|
||||||
echo "已复制 futures.db -> $RESTORE_DIR/futures.db"
|
echo "已复制 futures.db -> $RESTORE_DIR/futures.db"
|
||||||
@@ -218,7 +247,7 @@ if [ -d "$SCRIPT_DIR/uploads" ]; then
|
|||||||
fi
|
fi
|
||||||
echo ""
|
echo ""
|
||||||
echo "恢复完成。目标目录: $RESTORE_DIR"
|
echo "恢复完成。目标目录: $RESTORE_DIR"
|
||||||
echo "下一步: 确认 .env、pm2 restart qihuo"
|
echo "下一步: pm2 restart qihuo"
|
||||||
echo "详见 RESTORE.md 与 docs/POSTGRES.md"
|
echo "详见 RESTORE.md 与 docs/POSTGRES.md"
|
||||||
"""
|
"""
|
||||||
dest.write_text(script, encoding="utf-8")
|
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():
|
if include_uploads and upload_src.is_dir():
|
||||||
shutil.copytree(upload_src, work / "uploads", dirs_exist_ok=True)
|
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 = {
|
manifest = {
|
||||||
"app": "qihuo",
|
"app": "qihuo",
|
||||||
"backend": backend,
|
"backend": backend,
|
||||||
"created_at": datetime.now(TZ).isoformat(timespec="seconds"),
|
"created_at": datetime.now(TZ).isoformat(timespec="seconds"),
|
||||||
"db_path": DB_PATH if backend == "sqlite" else (os.getenv("DATABASE_URL") or ""),
|
"db_path": DB_PATH if backend == "sqlite" else (os.getenv("DATABASE_URL") or ""),
|
||||||
"includes_uploads": include_uploads and upload_src.is_dir(),
|
"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(),
|
"default_restore_dir": default_restore_dir(),
|
||||||
"files": sorted(p.name for p in work.iterdir()),
|
"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",
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
(work / "RESTORE.md").write_text(RESTORE_MD, 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:
|
with tarfile.open(out_path, "w:gz") as tar:
|
||||||
tar.add(work, arcname=folder_name)
|
tar.add(work, arcname=folder_name)
|
||||||
|
|||||||
@@ -25,6 +25,48 @@
|
|||||||
font-size: 0.78rem;
|
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 {
|
.dashboard-account-card {
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
@@ -46,6 +88,10 @@
|
|||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-server-card[hidden] {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.dash-server-head {
|
.dash-server-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
@@ -41,6 +41,9 @@
|
|||||||
var serverDiskSubEl = document.getElementById('dash-server-disk-sub');
|
var serverDiskSubEl = document.getElementById('dash-server-disk-sub');
|
||||||
var serverNetUpEl = document.getElementById('dash-server-net-up');
|
var serverNetUpEl = document.getElementById('dash-server-net-up');
|
||||||
var serverNetDownEl = document.getElementById('dash-server-net-down');
|
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 pollTimer = null;
|
||||||
var pollInFlight = false;
|
var pollInFlight = false;
|
||||||
@@ -78,6 +81,30 @@
|
|||||||
riskToggleEl.setAttribute('aria-expanded', expanded ? 'true' : 'false');
|
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() {
|
function initRiskToggle() {
|
||||||
if (!riskCardEl || !riskToggleEl) return;
|
if (!riskCardEl || !riskToggleEl) return;
|
||||||
if (shouldCollapseRiskDefault()) {
|
if (shouldCollapseRiskDefault()) {
|
||||||
@@ -524,6 +551,7 @@
|
|||||||
|
|
||||||
function applyServer(server) {
|
function applyServer(server) {
|
||||||
if (!server) return;
|
if (!server) return;
|
||||||
|
if (serverSummaryEl) serverSummaryEl.textContent = buildServerSummary(server);
|
||||||
if (serverHostEl) serverHostEl.textContent = server.hostname || '—';
|
if (serverHostEl) serverHostEl.textContent = server.hostname || '—';
|
||||||
if (serverUptimeEl) {
|
if (serverUptimeEl) {
|
||||||
serverUptimeEl.textContent = server.uptime_label
|
serverUptimeEl.textContent = server.uptime_label
|
||||||
@@ -1102,6 +1130,7 @@
|
|||||||
|
|
||||||
startPolling();
|
startPolling();
|
||||||
connectPositionStream();
|
connectPositionStream();
|
||||||
|
initServerToggle();
|
||||||
initRiskToggle();
|
initRiskToggle();
|
||||||
initDetailModal();
|
initDetailModal();
|
||||||
initMobileLists();
|
initMobileLists();
|
||||||
|
|||||||
@@ -11,33 +11,20 @@
|
|||||||
<div class="dashboard-top-left">
|
<div class="dashboard-top-left">
|
||||||
<span class="badge planned" id="dash-mode-badge">—</span>
|
<span class="badge planned" id="dash-mode-badge">—</span>
|
||||||
<span class="badge planned" id="dash-ctp-badge">CTP 检测中…</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>
|
<span class="text-muted dash-updated" id="dash-updated">正在加载…</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card dashboard-account-card">
|
<div class="card dashboard-section dashboard-server-card" id="dash-server-card" hidden>
|
||||||
<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="dash-server-head">
|
<div class="dash-server-head">
|
||||||
<div class="dash-server-title">
|
<div class="dash-server-title">
|
||||||
<span class="dash-server-dot" aria-hidden="true"></span>
|
<span class="dash-server-host" id="dash-server-host">—</span>
|
||||||
<span>服务器状态</span>
|
|
||||||
<span class="dash-server-host text-muted" id="dash-server-host">—</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dash-server-head-meta text-muted">
|
<div class="dash-server-head-meta text-muted">
|
||||||
<span id="dash-server-uptime"></span>
|
<span id="dash-server-uptime"></span>
|
||||||
@@ -92,6 +79,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<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">
|
<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>
|
<span class="dash-section-toggle-label">风控说明</span>
|
||||||
|
|||||||
@@ -535,7 +535,7 @@
|
|||||||
<button type="submit" class="btn-primary" {% if backup_running %}disabled{% endif %}>立即备份</button>
|
<button type="submit" class="btn-primary" {% if backup_running %}disabled{% endif %}>立即备份</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</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 %}
|
{% if backup_items %}
|
||||||
<table class="settings-backup-table">
|
<table class="settings-backup-table">
|
||||||
@@ -564,7 +564,7 @@
|
|||||||
<li>解压:<code>tar -xzf qihuo_backup_*.tar.gz</code></li>
|
<li>解压:<code>tar -xzf qihuo_backup_*.tar.gz</code></li>
|
||||||
<li>执行:<code>chmod +x restore.sh && ./restore.sh</code></li>
|
<li>执行:<code>chmod +x restore.sh && ./restore.sh</code></li>
|
||||||
<li>指定目录:<code>RESTORE_DIR=/opt/qihuo ./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>
|
</ol>
|
||||||
</details>
|
</details>
|
||||||
{% endcall %}
|
{% endcall %}
|
||||||
|
|||||||
Reference in New Issue
Block a user