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:
Vendored
+121
@@ -0,0 +1,121 @@
|
||||
"""读写实例目录 .env(行级 upsert,原子落盘)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from typing import Optional
|
||||
|
||||
_KEY_LINE = re.compile(r"^(\s*)([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)\s*$")
|
||||
|
||||
|
||||
def parse_env_lines(text: str) -> list[str]:
|
||||
return text.replace("\r\n", "\n").replace("\r", "\n").splitlines()
|
||||
|
||||
|
||||
def read_env_lines(path: str) -> list[str]:
|
||||
if not os.path.isfile(path):
|
||||
return []
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
return parse_env_lines(f.read())
|
||||
|
||||
|
||||
def env_get(lines: list[str], key: str) -> Optional[str]:
|
||||
for line in lines:
|
||||
m = _KEY_LINE.match(line)
|
||||
if m and m.group(2) == key:
|
||||
raw = m.group(3).strip()
|
||||
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
|
||||
return raw[1:-1]
|
||||
return raw
|
||||
return None
|
||||
|
||||
|
||||
def env_get_all(lines: list[str]) -> dict[str, str]:
|
||||
out: dict[str, str] = {}
|
||||
for line in lines:
|
||||
m = _KEY_LINE.match(line)
|
||||
if m:
|
||||
key = m.group(2)
|
||||
raw = m.group(3).strip()
|
||||
if (raw.startswith('"') and raw.endswith('"')) or (raw.startswith("'") and raw.endswith("'")):
|
||||
out[key] = raw[1:-1]
|
||||
else:
|
||||
out[key] = raw
|
||||
return out
|
||||
|
||||
|
||||
def upsert_env_line(lines: list[str], key: str, value: str) -> list[str]:
|
||||
pat = re.compile(r"^\s*" + re.escape(key) + r"\s*=")
|
||||
out: list[str] = []
|
||||
replaced = False
|
||||
safe = value if value is not None else ""
|
||||
if any(c in safe for c in (' ', '#', '"', "'")):
|
||||
safe = '"' + safe.replace("\\", "\\\\").replace('"', '\\"') + '"'
|
||||
new_line = f"{key}={safe}"
|
||||
for line in lines:
|
||||
if pat.match(line):
|
||||
if not replaced:
|
||||
out.append(new_line)
|
||||
replaced = True
|
||||
continue
|
||||
out.append(line)
|
||||
if not replaced:
|
||||
if out and out[-1].strip():
|
||||
out.append("")
|
||||
out.append(new_line)
|
||||
return out
|
||||
|
||||
|
||||
def write_env_lines_atomic(path: str, lines: list[str]) -> None:
|
||||
directory = os.path.dirname(os.path.abspath(path)) or "."
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".env.", dir=directory, text=True)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
||||
f.write("\n".join(lines))
|
||||
if lines:
|
||||
f.write("\n")
|
||||
os.replace(tmp, path)
|
||||
finally:
|
||||
if os.path.exists(tmp):
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def apply_env_updates(path: str, updates: dict[str, str]) -> list[str]:
|
||||
lines = read_env_lines(path)
|
||||
changed: list[str] = []
|
||||
for key, value in updates.items():
|
||||
if value is None:
|
||||
continue
|
||||
old = env_get(lines, key)
|
||||
if old == value:
|
||||
continue
|
||||
lines = upsert_env_line(lines, key, value)
|
||||
changed.append(key)
|
||||
if changed:
|
||||
write_env_lines_atomic(path, lines)
|
||||
return changed
|
||||
|
||||
|
||||
def load_env_file_into_environ(path: str) -> None:
|
||||
if not os.path.exists(path):
|
||||
return
|
||||
with open(path, "r", encoding="utf-8", errors="ignore") as f:
|
||||
text = f.read()
|
||||
if text.startswith("\ufeff"):
|
||||
text = text[1:]
|
||||
for line in parse_env_lines(text):
|
||||
s = line.strip()
|
||||
if not s or s.startswith("#"):
|
||||
continue
|
||||
if "=" not in s:
|
||||
continue
|
||||
k, _, v = s.partition("=")
|
||||
clean_key = k.strip()
|
||||
clean_val = v.strip().strip('"').strip("'")
|
||||
if clean_key:
|
||||
os.environ[clean_key] = clean_val
|
||||
Vendored
+252
@@ -0,0 +1,252 @@
|
||||
"""从 .env.example 构建 env 配置 schema(分组、敏感、重启标注)。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from typing import Any, Optional
|
||||
|
||||
from lib.env.env_file_lib import env_get, env_get_all, read_env_lines
|
||||
|
||||
_GROUP_RE = re.compile(r"^#\s*=+\s*(.+?)\s*=+\s*$")
|
||||
_KEY_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)\s*=")
|
||||
|
||||
RESTART_REQUIRED_EXACT = frozenset({
|
||||
"APP_HOST",
|
||||
"APP_PORT",
|
||||
"APP_DEBUG",
|
||||
"DB_PATH",
|
||||
"UPLOAD_DIR",
|
||||
"FLASK_SECRET_KEY",
|
||||
"POSITION_SIZING_MODE",
|
||||
"LIVE_TRADING_ENABLED",
|
||||
"OKX_TD_MODE",
|
||||
"OKX_POS_MODE",
|
||||
"OKX_POSITION_INST_TYPE",
|
||||
"BINANCE_MARGIN_MODE",
|
||||
"BINANCE_POSITION_MODE",
|
||||
"GATE_TD_MODE",
|
||||
"GATE_POS_MODE",
|
||||
"PM2_APP_NAME",
|
||||
})
|
||||
|
||||
RESTART_REQUIRED_PREFIXES = (
|
||||
"OKX_API_",
|
||||
"OKX_OPTIONS_API_",
|
||||
"BINANCE_API_",
|
||||
"GATE_API_",
|
||||
"OKX_SOCKS_",
|
||||
"OKX_HTTP_",
|
||||
"OKX_HTTPS_",
|
||||
"BINANCE_HTTP_",
|
||||
"BINANCE_HTTPS_",
|
||||
"GATE_HTTP_",
|
||||
"GATE_HTTPS_",
|
||||
)
|
||||
|
||||
HOT_RELOAD_EXACT = frozenset({
|
||||
"RISK_PERCENT",
|
||||
"MAX_ACTIVE_POSITIONS",
|
||||
"MANUAL_MIN_PLANNED_RR",
|
||||
"KEY_AUTO_MIN_PLANNED_RR",
|
||||
"DAILY_OPEN_ALERT_THRESHOLD",
|
||||
"DAILY_OPEN_HARD_LIMIT",
|
||||
"TRADING_DAY_RESET_HOUR",
|
||||
"TRADING_DAY_RESET_OPEN_GUARD_ENABLED",
|
||||
"RISK_CONTROL_ENABLED",
|
||||
"RISK_COOLING_HOURS_MANUAL",
|
||||
"RISK_COOLING_HOURS_MANUAL_JOURNAL",
|
||||
"RISK_MANUAL_CLOSE_DAILY_LIMIT",
|
||||
"RISK_MOOD_ISSUES_DAILY_FREEZE",
|
||||
"KEY_AUTO_ORDER_ENABLED",
|
||||
"TRADE_DIRECTION_RESTRICT_ENABLED",
|
||||
"TRADE_DIRECTION",
|
||||
"TRADE_SYMBOL_RESTRICT_ENABLED",
|
||||
"TRADE_SYMBOL_WHITELIST",
|
||||
"BALANCE_REFRESH_SECONDS",
|
||||
"PRICE_REFRESH_SECONDS",
|
||||
"MONITOR_POLL_SECONDS",
|
||||
"AUTO_TRANSFER_ENABLED",
|
||||
"AUTO_TRANSFER_AMOUNT",
|
||||
"AUTO_TRANSFER_BJ_HOUR",
|
||||
"FORCE_CLOSE_ENABLED",
|
||||
"FORCE_CLOSE_BJ_HOUR",
|
||||
"BTC_LEVERAGE",
|
||||
"ALT_LEVERAGE",
|
||||
"DAILY_START_CAPITAL",
|
||||
"DAILY_LOSS_CAPITAL",
|
||||
"DAILY_PROFIT_CAPITAL",
|
||||
"FULL_MARGIN_BUFFER_RATIO",
|
||||
"APP_USERNAME",
|
||||
"APP_PASSWORD",
|
||||
"APP_AUTH_DISABLED",
|
||||
"WECHAT_WEBHOOK",
|
||||
})
|
||||
|
||||
SENSITIVE_EXACT = frozenset({
|
||||
"APP_PASSWORD",
|
||||
"FLASK_SECRET_KEY",
|
||||
"HUB_BRIDGE_TOKEN",
|
||||
"OPENAI_API_KEY",
|
||||
})
|
||||
|
||||
SENSITIVE_SUBSTR = ("_SECRET", "_PASSPHRASE", "_API_KEY", "_PASSWORD")
|
||||
|
||||
|
||||
def _is_sensitive(key: str) -> bool:
|
||||
if key in SENSITIVE_EXACT:
|
||||
return True
|
||||
return any(s in key for s in SENSITIVE_SUBSTR)
|
||||
|
||||
|
||||
def _restart_required(key: str) -> bool:
|
||||
if key in HOT_RELOAD_EXACT:
|
||||
return False
|
||||
if key in RESTART_REQUIRED_EXACT:
|
||||
return True
|
||||
return any(key.startswith(p) for p in RESTART_REQUIRED_PREFIXES)
|
||||
|
||||
|
||||
def _hot_reload(key: str) -> bool:
|
||||
if key in HOT_RELOAD_EXACT:
|
||||
return True
|
||||
if _restart_required(key):
|
||||
return False
|
||||
return key.startswith(("KEY_", "KLINE_", "BREAKEVEN_", "RECONCILE_", "ORDER_CHART_"))
|
||||
|
||||
|
||||
def _field_type(key: str, value: str) -> str:
|
||||
low = (value or "").strip().lower()
|
||||
if low in ("true", "false"):
|
||||
return "bool"
|
||||
if key.endswith("_ENABLED") or key.startswith("RISK_MOOD_"):
|
||||
return "bool"
|
||||
try:
|
||||
if "." in low:
|
||||
float(low)
|
||||
return "float"
|
||||
int(low)
|
||||
return "int"
|
||||
except ValueError:
|
||||
pass
|
||||
return "text"
|
||||
|
||||
|
||||
def _mask_value(key: str, value: Optional[str]) -> dict[str, Any]:
|
||||
if value is None or value == "":
|
||||
return {"value": "", "masked": "", "has_value": False}
|
||||
if not _is_sensitive(key):
|
||||
return {"value": value, "masked": value, "has_value": True}
|
||||
tail = value[-4:] if len(value) >= 4 else value
|
||||
return {"value": "", "masked": f"****{tail}", "has_value": True}
|
||||
|
||||
|
||||
def parse_env_example_schema(example_path: str) -> list[dict[str, Any]]:
|
||||
if not os.path.isfile(example_path):
|
||||
return []
|
||||
lines = read_env_lines(example_path)
|
||||
groups: list[dict[str, Any]] = []
|
||||
group_map: dict[str, dict[str, Any]] = {}
|
||||
current_group = "应用与鉴权"
|
||||
pending_note: list[str] = []
|
||||
|
||||
def _ensure_group(title: str) -> dict[str, Any]:
|
||||
if title not in group_map:
|
||||
group_map[title] = {"title": title, "fields": []}
|
||||
groups.append(group_map[title])
|
||||
return group_map[title]
|
||||
|
||||
for raw in lines:
|
||||
line = raw.rstrip()
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
pending_note = []
|
||||
continue
|
||||
gm = _GROUP_RE.match(stripped)
|
||||
if gm:
|
||||
current_group = gm.group(1).strip()
|
||||
pending_note = []
|
||||
continue
|
||||
if stripped.startswith("#"):
|
||||
note = stripped.lstrip("#").strip()
|
||||
if note and not note.startswith("="):
|
||||
pending_note.append(note)
|
||||
continue
|
||||
km = _KEY_LINE.match(stripped)
|
||||
if not km:
|
||||
continue
|
||||
key = km.group(1)
|
||||
default_val = env_get(lines, key) or ""
|
||||
grp = _ensure_group(current_group)
|
||||
note = " ".join(pending_note).strip()
|
||||
grp["fields"].append(
|
||||
{
|
||||
"key": key,
|
||||
"label": key,
|
||||
"note": note,
|
||||
"default": default_val,
|
||||
"type": _field_type(key, default_val),
|
||||
"sensitive": _is_sensitive(key),
|
||||
"restart_required": _restart_required(key),
|
||||
"hot_reload": _hot_reload(key),
|
||||
}
|
||||
)
|
||||
pending_note = []
|
||||
return groups
|
||||
|
||||
|
||||
def build_env_payload(example_path: str, env_path: str) -> dict[str, Any]:
|
||||
groups = parse_env_example_schema(example_path)
|
||||
env_lines = read_env_lines(env_path)
|
||||
values = env_get_all(env_lines)
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
key = field["key"]
|
||||
val = values.get(key)
|
||||
if val is None:
|
||||
val = field.get("default") or ""
|
||||
masked = _mask_value(key, val)
|
||||
field["current"] = masked["value"] if not field["sensitive"] else ""
|
||||
field["masked"] = masked["masked"]
|
||||
field["has_value"] = masked["has_value"]
|
||||
return {"groups": groups}
|
||||
|
||||
|
||||
def validate_env_updates(groups: list[dict], updates: dict[str, str]) -> tuple[dict[str, str], list[str]]:
|
||||
allowed = {}
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
allowed[field["key"]] = field
|
||||
clean: dict[str, str] = {}
|
||||
errors: list[str] = []
|
||||
for key, value in (updates or {}).items():
|
||||
if key not in allowed:
|
||||
errors.append(f"未知配置项: {key}")
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
val = str(value).strip()
|
||||
if allowed[key].get("sensitive") and val == "":
|
||||
continue
|
||||
ftype = allowed[key].get("type")
|
||||
if ftype == "bool":
|
||||
low = val.lower()
|
||||
if low not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
|
||||
errors.append(f"{key} 须为 true/false")
|
||||
continue
|
||||
val = "true" if low in ("true", "1", "yes", "on") else "false"
|
||||
clean[key] = val
|
||||
return clean, errors
|
||||
|
||||
|
||||
def updates_need_restart(groups: list[dict], changed_keys: list[str]) -> bool:
|
||||
field_map = {}
|
||||
for group in groups:
|
||||
for field in group.get("fields") or []:
|
||||
field_map[field["key"]] = field
|
||||
for key in changed_keys:
|
||||
meta = field_map.get(key) or {}
|
||||
if meta.get("restart_required"):
|
||||
return True
|
||||
if not meta.get("hot_reload"):
|
||||
return True
|
||||
return False
|
||||
Reference in New Issue
Block a user