Add system settings, one-click deploy, and simplify embed UI.

Remove Gate scout auto-seeding and the manual hub login button while keeping Gate login and instance SSO. Add password change and database backup/restore, plus deploy scripts with env auto-generation.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-12 11:44:12 +08:00
parent f7ce6f1058
commit 3149770887
15 changed files with 635 additions and 242 deletions
+98 -88
View File
@@ -1,18 +1,20 @@
import json
import os
import secrets
import shutil
import urllib.error
import urllib.request
from datetime import datetime
from pathlib import Path
from typing import Optional
from urllib.parse import urlencode
from flask import Flask, flash, jsonify, redirect, render_template, request, url_for
from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for
from flask_login import LoginManager, current_user, login_required, login_user, logout_user
from flask_wtf.csrf import CSRFProtect
from werkzeug.middleware.proxy_fix import ProxyFix
from forms import GroupForm, LoginForm, ServiceForm
from forms import ChangePasswordForm, GroupForm, LoginForm, RestoreBackupForm, ServiceForm
from models import Service, ServiceGroup, User, db
_ROOT = Path(__file__).resolve().parent
@@ -301,7 +303,6 @@ def create_app() -> Flask:
_migrate_schema()
_ensure_default_user()
_ensure_admin_from_env()
_ensure_gate_scout_services()
@app.route("/login", methods=["GET", "POST"])
def login():
@@ -624,6 +625,68 @@ def create_app() -> Flask:
flash("服务已删除", "success")
return redirect(url_for("admin_services"))
# ---------- 系统设置 ----------
@app.route("/admin/settings", methods=["GET", "POST"])
@login_required
def admin_settings():
pwd_form = ChangePasswordForm()
restore_form = RestoreBackupForm()
db_path = _resolve_db_file()
db_size = db_path.stat().st_size if db_path.is_file() else 0
if pwd_form.validate_on_submit():
if not current_user.check_password(pwd_form.current_password.data):
flash("原密码不正确", "error")
else:
current_user.set_password(pwd_form.new_password.data)
db.session.commit()
flash("密码已更新", "success")
return redirect(url_for("admin_settings"))
if restore_form.validate_on_submit():
upload = restore_form.backup_file.data
if not upload or not upload.filename:
flash("请选择要恢复的 .db 文件", "error")
else:
try:
_restore_db_from_upload(upload)
flash(
"数据库已恢复。请重启 LocalNav 服务使变更完全生效。",
"success",
)
return redirect(url_for("admin_settings"))
except ValueError as exc:
flash(str(exc), "error")
except OSError as exc:
flash(f"恢复失败:{exc}", "error")
return render_template(
"admin_settings.html",
pwd_form=pwd_form,
restore_form=restore_form,
db_path=str(db_path),
db_size=db_size,
)
@app.route("/admin/settings/backup")
@login_required
def admin_settings_backup():
try:
db_path = _resolve_db_file()
except ValueError as exc:
flash(str(exc), "error")
return redirect(url_for("admin_settings"))
if not db_path.is_file():
flash("数据库文件不存在", "error")
return redirect(url_for("admin_settings"))
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return send_file(
db_path,
as_attachment=True,
download_name=f"nav_local_{ts}.db",
mimetype="application/octet-stream",
)
if os.environ.get("NAV_TRUST_PROXY") == "1":
app.wsgi_app = ProxyFix(
app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1, x_prefix=1
@@ -712,95 +775,42 @@ def _ensure_default_user() -> None:
)
def _ensure_gate_scout_services() -> None:
"""gate_scout_order:扫描端 + 执行器(NAV_SEED_GATE_SCOUT=1;云服务器用域名 + https"""
flag = (os.environ.get("NAV_SEED_GATE_SCOUT") or "").strip().lower()
if flag not in ("1", "true", "yes", "on"):
return
def _resolve_db_file() -> Path:
"""从 NAV_DATABASE_URL 解析 SQLite 文件路径"""
uri = os.environ.get("NAV_DATABASE_URL", "sqlite:///nav_local.db")
if not uri.startswith("sqlite:///"):
raise ValueError("当前仅支持 SQLite 数据库的备份与恢复")
path_part = uri[len("sqlite:///") :]
if not path_part:
raise ValueError("数据库路径配置无效")
if path_part.startswith("/"):
return Path(path_part)
return _ROOT / path_part
scheme = (os.environ.get("NAV_GATE_SCOUT_SCHEME") or "http").strip().lower()
if scheme not in ("http", "https"):
scheme = "http"
default_host = (os.environ.get("NAV_GATE_SCOUT_HOST") or "127.0.0.1").strip() or "127.0.0.1"
scout_host = (os.environ.get("NAV_GATE_SCOUT_SCOUT_HOST") or default_host).strip() or default_host
exec_host = (os.environ.get("NAV_GATE_EXECUTOR_HOST") or default_host).strip() or default_host
default_port = "443" if scheme == "https" else "8088"
default_exec_port = "443" if scheme == "https" else "8090"
try:
scout_port = int(os.environ.get("NAV_GATE_SCOUT_PORT") or default_port)
exec_port = int(os.environ.get("NAV_GATE_EXECUTOR_PORT") or default_exec_port)
except ValueError:
scout_port = 443 if scheme == "https" else 8088
exec_port = 443 if scheme == "https" else 8090
scout_path = (os.environ.get("NAV_GATE_SCOUT_PATH") or "/dashboard").strip() or "/dashboard"
exec_path = (os.environ.get("NAV_GATE_EXECUTOR_PATH") or "/dashboard").strip() or "/dashboard"
if not scout_path.startswith("/"):
scout_path = "/" + scout_path
if not exec_path.startswith("/"):
exec_path = "/" + exec_path
def _restore_db_from_upload(upload) -> None:
filename = (upload.filename or "").lower()
if not filename.endswith(".db"):
raise ValueError("仅支持 .db 格式的 SQLite 备份文件")
upload.seek(0, os.SEEK_END)
size = upload.tell()
upload.seek(0)
if size > 50 * 1024 * 1024:
raise ValueError("备份文件过大(上限 50MB")
if size < 512:
raise ValueError("备份文件过小,可能已损坏")
update_existing = (os.environ.get("NAV_GATE_SCOUT_UPDATE") or "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
db_path = _resolve_db_file()
db_path.parent.mkdir(parents=True, exist_ok=True)
if db_path.is_file():
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
bak = db_path.with_name(f"{db_path.name}.bak.{ts}")
shutil.copy2(db_path, bak)
group_name = (os.environ.get("NAV_GATE_SCOUT_GROUP") or "Gate 扫单").strip() or "Gate 扫单"
g = ServiceGroup.query.filter_by(name=group_name).first()
if not g:
g = ServiceGroup(name=group_name, sort_order=50)
db.session.add(g)
db.session.flush()
defs = (
("Gate 扫描端", scout_host, scout_port, scout_path, 0, "gate_scout"),
("Gate 下单执行器", exec_host, exec_port, exec_path, 10, "gate_exec"),
)
added = 0
updated = 0
for name, h, port, path, order, embed_k in defs:
existing = Service.query.filter_by(group_id=g.id, name=name).first()
if existing:
if update_existing and (
existing.scheme != scheme
or existing.host != h
or existing.port != port
or existing.path != path
or (existing.embed_kind or "") != embed_k
):
existing.scheme = scheme
existing.host = h
existing.port = port
existing.path = path
existing.embed_kind = embed_k
updated += 1
elif not (existing.embed_kind or "").strip():
existing.embed_kind = embed_k
updated += 1
continue
db.session.add(
Service(
name=name,
scheme=scheme,
host=h,
port=port,
path=path,
sort_order=order,
group_id=g.id,
embed_kind=embed_k,
)
)
added += 1
if added or updated:
db.session.commit()
print(
f"[nav] Gate 扫单:新增 {added}、更新 {updated}(分组「{group_name}」)。"
f" 扫描 {scheme}://{scout_host}:{scout_port}{scout_path}"
f"执行器 {scheme}://{exec_host}:{exec_port}{exec_path}",
flush=True,
)
db.session.remove()
db.engine.dispose()
with open(db_path, "wb") as out:
shutil.copyfileobj(upload.stream, out)
def _ensure_admin_from_env() -> None: