Add instance nav prefs, env config UI, and PM2 restart.
P1-P4: configurable nav/section visibility in system settings, full .env editor with restart badges, password change, runtime hot overrides, and single-instance pm2 restart. Works in hub embed iframe. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
"""实例系统设置 API:导航开关、env 读写、改密、PM2 重启。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from functools import wraps
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import jsonify, request, session
|
||||
|
||||
from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines
|
||||
from lib.env.env_schema import (
|
||||
build_env_payload,
|
||||
parse_env_example_schema,
|
||||
updates_need_restart,
|
||||
validate_env_updates,
|
||||
)
|
||||
from lib.instance.instance_display_prefs_lib import (
|
||||
display_meta_for_ui,
|
||||
get_display_prefs,
|
||||
normalize_display_prefs,
|
||||
save_display_prefs,
|
||||
tab_allowed,
|
||||
)
|
||||
from lib.instance.instance_pm2_lib import restart_instance_pm2
|
||||
from lib.instance.runtime_config_lib import apply_env_reload
|
||||
|
||||
|
||||
def _api_login_required(hub_token_write_allowed: bool = False):
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def wrapped(*args, **kwargs):
|
||||
from lib.hub.hub_auth import request_allowed as hub_request_allowed
|
||||
|
||||
logged_in = bool(session.get("logged_in"))
|
||||
auth_disabled = (os.getenv("APP_AUTH_DISABLED") or "").strip().lower() in (
|
||||
"1",
|
||||
"true",
|
||||
"yes",
|
||||
"on",
|
||||
)
|
||||
hub_hdr = (request.headers.get("X-Hub-Token") or "").strip()
|
||||
bridge = (os.getenv("HUB_BRIDGE_TOKEN") or "").strip()
|
||||
if hub_hdr and bridge and hub_hdr == bridge and not hub_token_write_allowed:
|
||||
return jsonify({"ok": False, "msg": "Hub Token 不可修改实例设置"}), 403
|
||||
if hub_request_allowed(logged_in, auth_disabled):
|
||||
return f(*args, **kwargs)
|
||||
return jsonify({"ok": False, "msg": "未登录"}), 401
|
||||
|
||||
return wrapped
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def register_instance_settings_routes(
|
||||
app,
|
||||
*,
|
||||
get_db: Callable,
|
||||
login_required_fn: Callable,
|
||||
base_dir: str,
|
||||
exchange_key: str,
|
||||
username: str,
|
||||
password: str,
|
||||
) -> None:
|
||||
env_path = os.path.join(base_dir, ".env")
|
||||
example_path = os.path.join(base_dir, ".env.example")
|
||||
api_auth = _api_login_required()
|
||||
|
||||
@app.route("/api/settings/display", methods=["GET"])
|
||||
@api_auth
|
||||
def api_get_display_prefs():
|
||||
prefs = get_display_prefs(get_db)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"display": prefs,
|
||||
"meta": display_meta_for_ui(),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/settings/display", methods=["POST"])
|
||||
@api_auth
|
||||
def api_save_display_prefs():
|
||||
body = request.get_json(silent=True) or {}
|
||||
raw = body.get("display") if isinstance(body.get("display"), dict) else body
|
||||
saved = save_display_prefs(get_db, raw)
|
||||
return jsonify({"ok": True, "display": saved})
|
||||
|
||||
@app.route("/api/settings/env/meta", methods=["GET"])
|
||||
@api_auth
|
||||
def api_env_meta():
|
||||
payload = build_env_payload(example_path, env_path)
|
||||
return jsonify({"ok": True, **payload})
|
||||
|
||||
@app.route("/api/settings/env", methods=["GET"])
|
||||
@api_auth
|
||||
def api_env_get():
|
||||
payload = build_env_payload(example_path, env_path)
|
||||
return jsonify({"ok": True, **payload})
|
||||
|
||||
@app.route("/api/settings/env", methods=["POST"])
|
||||
@api_auth
|
||||
def api_env_post():
|
||||
body = request.get_json(silent=True) or {}
|
||||
updates = body.get("values") if isinstance(body.get("values"), dict) else body
|
||||
if not isinstance(updates, dict):
|
||||
return jsonify({"ok": False, "msg": "无效请求体"}), 400
|
||||
groups = parse_env_example_schema(example_path)
|
||||
clean, errors = validate_env_updates(groups, updates)
|
||||
if errors:
|
||||
return jsonify({"ok": False, "msg": "; ".join(errors)}), 400
|
||||
if not clean:
|
||||
return jsonify({"ok": True, "changed_keys": [], "restart_required": False})
|
||||
changed = apply_env_updates(env_path, clean)
|
||||
reload_info = apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify(
|
||||
{
|
||||
"ok": True,
|
||||
"changed_keys": changed,
|
||||
"restart_required": reload_info.get("restart_required", False),
|
||||
}
|
||||
)
|
||||
|
||||
@app.route("/api/settings/password", methods=["POST"])
|
||||
@api_auth
|
||||
def api_change_password():
|
||||
body = request.get_json(silent=True) or {}
|
||||
old_password = str(body.get("old_password") or "")
|
||||
new_username = str(body.get("new_username") or "").strip()
|
||||
new_password = str(body.get("new_password") or "")
|
||||
confirm = str(body.get("confirm_password") or "")
|
||||
if not old_password or old_password != password:
|
||||
return jsonify({"ok": False, "msg": "当前密码错误"}), 400
|
||||
if len(new_password) < 6:
|
||||
return jsonify({"ok": False, "msg": "新密码至少 6 位"}), 400
|
||||
if new_password != confirm:
|
||||
return jsonify({"ok": False, "msg": "两次输入的新密码不一致"}), 400
|
||||
updates: dict[str, str] = {"APP_PASSWORD": new_password}
|
||||
if new_username:
|
||||
updates["APP_USERNAME"] = new_username
|
||||
changed = apply_env_updates(env_path, updates)
|
||||
groups = parse_env_example_schema(example_path)
|
||||
apply_env_reload(env_path, get_db, changed, groups)
|
||||
return jsonify({"ok": True, "restart_required": True, "changed_keys": changed})
|
||||
|
||||
@app.route("/api/admin/restart", methods=["POST"])
|
||||
@api_auth
|
||||
def api_admin_restart():
|
||||
result = restart_instance_pm2(exchange_key)
|
||||
code = 200 if result.get("ok") else 500
|
||||
return jsonify({"ok": bool(result.get("ok")), **result}), code
|
||||
|
||||
@app.route("/api/admin/health", methods=["GET"])
|
||||
def api_admin_health():
|
||||
return jsonify({"ok": True, "status": "up"})
|
||||
|
||||
def tab_allowed_fn(tab: str) -> bool:
|
||||
prefs = get_display_prefs(get_db)
|
||||
return tab_allowed(tab, prefs)
|
||||
|
||||
app.config["INSTANCE_GET_DB"] = get_db
|
||||
app.config["INSTANCE_TAB_ALLOWED_FN"] = tab_allowed_fn
|
||||
|
||||
@app.route("/api/embed/tab_allowed/<tab>", methods=["GET"])
|
||||
@api_auth
|
||||
def api_tab_allowed(tab: str):
|
||||
prefs = get_display_prefs(get_db)
|
||||
return jsonify({"ok": True, "tab": tab, "allowed": tab_allowed(tab, prefs)})
|
||||
|
||||
|
||||
def merge_ui_template_context(page: str, get_db: Callable, **settings_kwargs: Any) -> dict[str, Any]:
|
||||
from lib.instance.instance_settings_lib import settings_page_context
|
||||
|
||||
prefs = get_display_prefs(get_db)
|
||||
ctx = {
|
||||
"display": prefs,
|
||||
**settings_page_context(page, **settings_kwargs),
|
||||
}
|
||||
return ctx
|
||||
Reference in New Issue
Block a user