From ef4b2f17ca7efa705827582a445e6fd7a09f8a7c Mon Sep 17 00:00:00 2001 From: dekun Date: Wed, 8 Jul 2026 20:07:06 +0800 Subject: [PATCH] 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 --- crypto_monitor_binance/app.py | 28 ++ crypto_monitor_gate/app.py | 28 ++ crypto_monitor_okx/app.py | 26 ++ lib/common/static/instance_embed.js | 31 +- lib/common/static/instance_settings_prefs.js | 289 ++++++++++++++++++ lib/common/static/instance_theme.css | 147 +++++++++ lib/env/env_file_lib.py | 121 ++++++++ lib/env/env_schema.py | 252 +++++++++++++++ lib/instance/instance_display_prefs_lib.py | 118 +++++++ lib/instance/instance_embed_context_lib.py | 2 +- lib/instance/instance_embed_lib.py | 5 + lib/instance/instance_pm2_lib.py | 49 +++ lib/instance/instance_settings_lib.py | 3 +- lib/instance/instance_settings_register.py | 178 +++++++++++ lib/instance/runtime_config_lib.py | 62 ++++ lib/instance/runtime_settings_lib.py | 71 +++++ .../templates/display_prefs_panel.html | 12 + .../templates/embed_page_fragment.html | 3 + lib/instance/templates/embed_shell.html | 27 +- lib/instance/templates/env_config_panel.html | 16 + lib/instance/templates/index.html | 27 +- .../templates/password_settings_panel.html | 15 + lib/instance/templates/settings_panel.html | 18 +- tests/test_instance_display_env_settings.py | 57 ++++ tests/test_instance_embed_lib.py | 2 + 25 files changed, 1570 insertions(+), 17 deletions(-) create mode 100644 lib/common/static/instance_settings_prefs.js create mode 100644 lib/env/env_file_lib.py create mode 100644 lib/env/env_schema.py create mode 100644 lib/instance/instance_display_prefs_lib.py create mode 100644 lib/instance/instance_pm2_lib.py create mode 100644 lib/instance/instance_settings_register.py create mode 100644 lib/instance/runtime_config_lib.py create mode 100644 lib/instance/runtime_settings_lib.py create mode 100644 lib/instance/templates/display_prefs_panel.html create mode 100644 lib/instance/templates/env_config_panel.html create mode 100644 lib/instance/templates/password_settings_panel.html create mode 100644 tests/test_instance_display_env_settings.py diff --git a/crypto_monitor_binance/app.py b/crypto_monitor_binance/app.py index 970e0a5..41fbbc3 100644 --- a/crypto_monitor_binance/app.py +++ b/crypto_monitor_binance/app.py @@ -1235,6 +1235,11 @@ def init_db(): (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""" + ) c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique ON transfer_logs(transfer_type, transfer_day) @@ -7252,6 +7257,7 @@ def render_main_page(page="trade", embed_mode=None): conn.close() from lib.instance.instance_embed_lib import embed_context_extras from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context template_ctx = dict( page=page, @@ -7334,6 +7340,7 @@ def render_main_page(page="trade", embed_mode=None): kline_timeframe=KLINE_TIMEFRAME, **strategy_extra, **embed_context_extras("binance"), + **display_prefs_template_context(get_db), **settings_page_context( page, exchange_key="binance", @@ -7391,6 +7398,12 @@ def risk_policy_page(): return render_main_page("risk_policy") +@app.route("/env_config") +@login_required +def env_config_page(): + return render_main_page("env_config") + + @app.route("/settings") @login_required def settings_page(): @@ -9883,6 +9896,21 @@ try: except Exception as _hub_err: print(f"[hub_bridge] binance: {_hub_err}") +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="binance", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] binance: {_settings_err}") + @app.route("/strategy") @login_required diff --git a/crypto_monitor_gate/app.py b/crypto_monitor_gate/app.py index 3d0d116..e246323 100644 --- a/crypto_monitor_gate/app.py +++ b/crypto_monitor_gate/app.py @@ -1226,6 +1226,11 @@ def init_db(): (id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT, amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''') + c.execute( + """CREATE TABLE IF NOT EXISTS app_runtime_settings + (key TEXT PRIMARY KEY, value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)""" + ) c.execute('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''') c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique ON transfer_logs(transfer_type, transfer_day) @@ -7046,6 +7051,7 @@ def render_main_page(page="trade", embed_mode=None): conn.close() from lib.instance.instance_embed_lib import embed_context_extras from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context template_ctx = dict( page=page, @@ -7137,6 +7143,7 @@ def render_main_page(page="trade", embed_mode=None): has_active_positions=bool(order_list), ), **embed_context_extras("gate"), + **display_prefs_template_context(get_db), **settings_page_context( page, exchange_key="gate", @@ -7206,6 +7213,12 @@ def risk_policy_page(): return render_main_page("risk_policy") +@app.route("/env_config") +@login_required +def env_config_page(): + return render_main_page("env_config") + + @app.route("/settings") @login_required def settings_page(): @@ -9758,6 +9771,21 @@ try: except Exception as _hub_err: print(f"[hub_bridge] gate: {_hub_err}") +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="gate", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] gate: {_settings_err}") + @app.route("/strategy") @login_required diff --git a/crypto_monitor_okx/app.py b/crypto_monitor_okx/app.py index 7cc4b99..1bc0cfc 100644 --- a/crypto_monitor_okx/app.py +++ b/crypto_monitor_okx/app.py @@ -6645,6 +6645,7 @@ def render_main_page(page="trade", embed_mode=None): conn.close() from lib.instance.instance_embed_lib import embed_context_extras from lib.instance.instance_settings_lib import settings_page_context + from lib.instance.instance_display_prefs_lib import display_prefs_template_context template_ctx = dict( page=page, @@ -6743,6 +6744,7 @@ def render_main_page(page="trade", embed_mode=None): exchange_pnl_sync=exchange_pnl_sync, **strategy_extra, **embed_context_extras("okx"), + **display_prefs_template_context(get_db), **settings_page_context( page, exchange_key="okx", @@ -6828,6 +6830,15 @@ def risk_policy_page(): return render_main_page("risk_policy") +@app.route("/env_config") +@login_required +def env_config_page(): + redir = redirect_to_embed_shell_if_enabled("env_config") + if redir is not None: + return redir + return render_main_page("env_config") + + @app.route("/settings") @login_required def settings_page(): @@ -9432,6 +9443,21 @@ try: except Exception as _hub_err: print(f"[hub_bridge] okx: {_hub_err}") +try: + from lib.instance.instance_settings_register import register_instance_settings_routes + + register_instance_settings_routes( + app, + get_db=get_db, + login_required_fn=login_required, + base_dir=BASE_DIR, + exchange_key="okx", + username=USERNAME, + password=PASSWORD, + ) +except Exception as _settings_err: + print(f"[instance_settings] okx: {_settings_err}") + @app.route("/strategy") @login_required diff --git a/lib/common/static/instance_embed.js b/lib/common/static/instance_embed.js index b33659b..c95391b 100644 --- a/lib/common/static/instance_embed.js +++ b/lib/common/static/instance_embed.js @@ -12,6 +12,7 @@ records: "/records", stats: "/stats", risk_policy: "/risk_policy", + env_config: "/env_config", settings: "/settings", }; @@ -52,6 +53,13 @@ }); } + function pageNavAllowed(tab) { + if (global.InstanceSettingsPrefs && typeof global.InstanceSettingsPrefs.pageNavAllowed === "function") { + return global.InstanceSettingsPrefs.pageNavAllowed(tab); + } + return true; + } + function syncUrl(tab, replace) { const q = new URLSearchParams(location.search); q.set("tab", tab); @@ -99,6 +107,16 @@ if (!revisit && tab === "stats") { if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl(); } + if (!revisit && (tab === "settings" || tab === "env_config")) { + if (global.InstanceSettingsPrefs) { + if (tab === "settings" && typeof global.InstanceSettingsPrefs.loadDisplayPrefsForm === "function") { + global.InstanceSettingsPrefs.loadDisplayPrefsForm(); + } + if (tab === "env_config" && typeof global.InstanceSettingsPrefs.loadEnvConfig === "function") { + global.InstanceSettingsPrefs.loadEnvConfig(); + } + } + } if (!revisit) { if (typeof global.refreshAccountSnapshot === "function") { global.refreshAccountSnapshot({ silent: true }); @@ -243,7 +261,7 @@ } function syncShellChrome(tab) { - const hideTopBar = tab === "settings" || tab === "risk_policy"; + const hideTopBar = tab === "settings" || tab === "risk_policy" || tab === "env_config"; document.querySelectorAll(".instance-top-bar").forEach((el) => { el.hidden = hideTopBar; }); @@ -286,6 +304,10 @@ async function loadTab(tab, opts) { const options = opts || {}; if (!tab) return; + if (!pageNavAllowed(tab)) { + void loadTab("trade", { replace: true }); + return; + } if (tabPanes.has(tab) && !options.force) { activateTab(tab, Object.assign({}, options, { revisit: true })); @@ -434,7 +456,12 @@ patchApplyListWindow(); patchHardNavigations(); initBootPane(); - if (getTab() === "settings") { + const bootTab = getTab(); + if (!pageNavAllowed(bootTab)) { + void loadTab("trade", { replace: true }); + return; + } + if (bootTab === "settings") { initPaneThemeToggle("settings"); } bindNav(); diff --git a/lib/common/static/instance_settings_prefs.js b/lib/common/static/instance_settings_prefs.js new file mode 100644 index 0000000..8098613 --- /dev/null +++ b/lib/common/static/instance_settings_prefs.js @@ -0,0 +1,289 @@ +/** + * 实例:导航显示、env 配置、改密、PM2 重启。 + */ +(function (global) { + const DISPLAY = () => global.__INSTANCE_DISPLAY__ || {}; + + function setStatus(el, text, isErr) { + if (!el) return; + el.textContent = text || ""; + el.classList.toggle("err", !!isErr); + } + + async function fetchJson(url, opts) { + const res = await fetch(url, Object.assign({ credentials: "same-origin" }, opts || {})); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + throw new Error(data.msg || res.statusText || "请求失败"); + } + return data; + } + + function applyDisplayToNav(display) { + const map = { + strategy: "show_nav_strategy", + strategy_records: "show_nav_strategy_records", + records: "show_nav_records", + stats: "show_nav_stats", + options: "show_nav_options", + risk_policy: "show_nav_risk_policy", + env_config: "show_nav_env_config", + }; + document.querySelectorAll(".embed-top-nav [data-embed-tab], .top-nav a[href^='/']").forEach((a) => { + const tab = a.getAttribute("data-embed-tab") || (a.getAttribute("href") || "").replace(/^\//, "").split("?")[0]; + const key = map[tab]; + if (!key) return; + const show = display[key] !== false; + a.classList.toggle("nav-hidden", !show); + a.style.display = show ? "" : "none"; + }); + global.__INSTANCE_DISPLAY__ = display; + } + + function pageNavAllowed(tab) { + const d = DISPLAY(); + const map = { + strategy: "show_nav_strategy", + strategy_records: "show_nav_strategy_records", + records: "show_nav_records", + stats: "show_nav_stats", + options: "show_nav_options", + risk_policy: "show_nav_risk_policy", + env_config: "show_nav_env_config", + }; + const key = map[tab]; + if (!key) return true; + return d[key] !== false; + } + + async function loadDisplayPrefsForm() { + const root = document.getElementById("display-prefs-form"); + if (!root) return; + try { + const data = await fetchJson("/api/settings/display"); + const display = data.display || {}; + const meta = data.meta || []; + root.innerHTML = ""; + meta.forEach((group) => { + const section = document.createElement("div"); + section.className = "display-prefs-group"; + const title = document.createElement("h3"); + title.className = "settings-subcard-title"; + title.textContent = group.group; + section.appendChild(title); + const grid = document.createElement("div"); + grid.className = "display-prefs-checks"; + (group.keys || []).forEach((item) => { + const label = document.createElement("label"); + label.className = "chk-label"; + const cb = document.createElement("input"); + cb.type = "checkbox"; + cb.dataset.prefKey = item.key; + cb.checked = display[item.key] !== false; + label.appendChild(cb); + label.appendChild(document.createTextNode(" " + item.label)); + grid.appendChild(label); + }); + section.appendChild(grid); + root.appendChild(section); + }); + } catch (e) { + root.innerHTML = '' + (e.message || "加载失败") + ""; + } + } + + async function saveDisplayPrefs() { + const status = document.getElementById("display-prefs-status"); + const display = {}; + document.querySelectorAll("#display-prefs-form input[data-pref-key]").forEach((cb) => { + display[cb.dataset.prefKey] = !!cb.checked; + }); + try { + const data = await fetchJson("/api/settings/display", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display }), + }); + applyDisplayToNav(data.display || display); + setStatus(status, "已保存,导航已更新"); + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + let envSchemaGroups = []; + + function renderEnvField(field) { + const card = document.createElement("div"); + card.className = "env-field-card"; + const label = document.createElement("label"); + label.className = "env-field-label"; + label.textContent = field.key; + card.appendChild(label); + if (field.note) { + const note = document.createElement("div"); + note.className = "env-field-note muted"; + note.textContent = field.note; + card.appendChild(note); + } + let input; + if (field.type === "bool") { + input = document.createElement("select"); + ["true", "false"].forEach((v) => { + const o = document.createElement("option"); + o.value = v; + o.textContent = v; + input.appendChild(o); + }); + const cur = (field.current || field.default || "false").toLowerCase(); + input.value = cur === "true" || cur === "1" ? "true" : "false"; + } else { + input = document.createElement("input"); + input.type = field.sensitive ? "password" : "text"; + if (field.sensitive && field.has_value) { + input.placeholder = field.masked || "留空不修改"; + } else { + input.value = field.current || field.default || ""; + } + } + input.dataset.envKey = field.key; + input.className = "env-field-input"; + card.appendChild(input); + const badge = document.createElement("span"); + badge.className = "env-field-badge " + (field.restart_required ? "env-badge-restart" : "env-badge-hot"); + badge.textContent = field.restart_required ? "需重启" : "保存即生效"; + card.appendChild(badge); + return card; + } + + async function loadEnvConfig() { + const grid = document.getElementById("env-config-grid"); + if (!grid) return; + grid.innerHTML = '
加载配置中…
'; + try { + const data = await fetchJson("/api/settings/env"); + envSchemaGroups = data.groups || []; + grid.innerHTML = ""; + envSchemaGroups.forEach((group) => { + const card = document.createElement("div"); + card.className = "card env-group-card"; + const h = document.createElement("h3"); + h.className = "env-group-title"; + h.textContent = group.title || "其他"; + card.appendChild(h); + const inner = document.createElement("div"); + inner.className = "env-group-fields"; + (group.fields || []).forEach((field) => inner.appendChild(renderEnvField(field))); + card.appendChild(inner); + grid.appendChild(card); + }); + } catch (e) { + grid.innerHTML = '' + (e.message || "加载失败") + ""; + } + } + + function collectEnvValues() { + const values = {}; + document.querySelectorAll(".env-field-input[data-env-key]").forEach((el) => { + values[el.dataset.envKey] = el.value; + }); + return values; + } + + async function saveEnvConfig(restartAfter) { + const status = document.getElementById("env-config-status"); + setStatus(status, "保存中…"); + try { + const data = await fetchJson("/api/settings/env", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ values: collectEnvValues() }), + }); + const needRestart = restartAfter || data.restart_required; + if (needRestart) { + setStatus(status, "已保存,正在重启实例…"); + await restartInstance(); + setStatus(status, "保存并重启完成"); + await loadEnvConfig(); + } else { + setStatus(status, "已保存(即时生效项已应用)"); + await loadEnvConfig(); + } + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + async function restartInstance() { + await fetchJson("/api/admin/restart", { method: "POST" }); + const deadline = Date.now() + 90000; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 2000)); + try { + const h = await fetch("/api/admin/health", { credentials: "same-origin" }); + if (h.ok) return; + } catch (_) {} + } + throw new Error("重启后服务未在预期时间内恢复"); + } + + async function savePassword() { + const status = document.getElementById("pwd-save-status"); + const body = { + old_password: (document.getElementById("pwd-old") || {}).value || "", + new_username: (document.getElementById("pwd-new-username") || {}).value || "", + new_password: (document.getElementById("pwd-new") || {}).value || "", + confirm_password: (document.getElementById("pwd-confirm") || {}).value || "", + }; + try { + const data = await fetchJson("/api/settings/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (data.restart_required) { + setStatus(status, "密码已保存,正在重启…"); + await restartInstance(); + setStatus(status, "密码已更新,请用新密码登录"); + } else { + setStatus(status, "密码已更新"); + } + } catch (e) { + setStatus(status, e.message || "保存失败", true); + } + } + + function bindEvents() { + const dSave = document.getElementById("display-prefs-save"); + if (dSave) dSave.addEventListener("click", saveDisplayPrefs); + const eSave = document.getElementById("env-config-save"); + if (eSave) eSave.addEventListener("click", () => saveEnvConfig(false)); + const eRestart = document.getElementById("env-config-save-restart"); + if (eRestart) eRestart.addEventListener("click", () => saveEnvConfig(true)); + const eReload = document.getElementById("env-config-reload"); + if (eReload) eReload.addEventListener("click", loadEnvConfig); + const pSave = document.getElementById("pwd-save-btn"); + if (pSave) pSave.addEventListener("click", savePassword); + } + + function initPage() { + bindEvents(); + if (document.getElementById("display-prefs-form")) loadDisplayPrefsForm(); + if (document.getElementById("env-config-grid")) loadEnvConfig(); + if (global.__INSTANCE_DISPLAY__) applyDisplayToNav(global.__INSTANCE_DISPLAY__); + } + + global.InstanceSettingsPrefs = { + pageNavAllowed, + applyDisplayToNav, + loadDisplayPrefsForm, + loadEnvConfig, + restartInstance, + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initPage); + } else { + initPage(); + } +})(typeof window !== "undefined" ? window : globalThis); diff --git a/lib/common/static/instance_theme.css b/lib/common/static/instance_theme.css index 7a32a71..99c5983 100644 --- a/lib/common/static/instance_theme.css +++ b/lib/common/static/instance_theme.css @@ -2181,6 +2181,153 @@ html[data-theme="light"] .journal-detail-img-thumb { background: #eef2f7; } +.nav-hidden { + display: none !important; +} + +/* ── env 配置页(三列卡片) ── */ +.env-config-page { + margin-top: 12px; +} + +.env-config-head { + padding: 12px 14px; + margin-bottom: 12px; +} + +.env-config-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; +} + +.env-config-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; + align-items: start; +} + +.env-group-card { + padding: 10px 12px; + min-width: 0; +} + +.env-group-title { + margin: 0 0 8px; + font-size: 0.88rem; + font-weight: 600; +} + +.env-group-fields { + display: flex; + flex-direction: column; + gap: 8px; +} + +.env-field-card { + display: flex; + flex-direction: column; + gap: 4px; +} + +.env-field-label { + font-size: 0.72rem; + font-weight: 600; + color: #a8b0cc; +} + +.env-field-note { + font-size: 0.68rem; + line-height: 1.35; +} + +.env-field-input { + width: 100%; + font-size: 0.78rem; +} + +.env-field-badge { + font-size: 0.65rem; + align-self: flex-start; + padding: 1px 6px; + border-radius: 4px; +} + +.env-badge-hot { + background: rgba(56, 178, 120, 0.15); + color: #6ee7a8; +} + +.env-badge-restart { + background: rgba(251, 191, 36, 0.12); + color: #fbbf24; +} + +.display-prefs-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 8px; +} + +.display-prefs-checks { + display: flex; + flex-wrap: wrap; + gap: 8px 16px; +} + +.display-prefs-checks .chk-label { + font-size: 0.82rem; + display: inline-flex; + align-items: center; + gap: 6px; +} + +.settings-password-form { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px 12px; + margin: 8px 0; +} + +.settings-password-form label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 0.78rem; + color: var(--muted, #8892b0); +} + +.settings-actions-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 8px; +} + +.settings-status-line.err { + color: var(--danger, #f87171); +} + +@media (max-width: 1100px) { + .env-config-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 720px) { + .env-config-grid { + grid-template-columns: minmax(0, 1fr); + } + .settings-password-form { + grid-template-columns: minmax(0, 1fr); + } +} + /* ── 风控说明页 ── */ .risk-policy-page { margin-top: 12px; diff --git a/lib/env/env_file_lib.py b/lib/env/env_file_lib.py new file mode 100644 index 0000000..25def74 --- /dev/null +++ b/lib/env/env_file_lib.py @@ -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 diff --git a/lib/env/env_schema.py b/lib/env/env_schema.py new file mode 100644 index 0000000..cea5ac7 --- /dev/null +++ b/lib/env/env_schema.py @@ -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 diff --git a/lib/instance/instance_display_prefs_lib.py b/lib/instance/instance_display_prefs_lib.py new file mode 100644 index 0000000..ca5b02e --- /dev/null +++ b/lib/instance/instance_display_prefs_lib.py @@ -0,0 +1,118 @@ +"""实例顶栏 / 系统设置区块显示开关(存 SQLite,即时生效)。""" +from __future__ import annotations + +from typing import Any, Callable, Optional + +from lib.instance.runtime_settings_lib import runtime_get_prefix, runtime_set_many, with_db + +DISPLAY_RUNTIME_PREFIX = "display." + +DEFAULT_INSTANCE_DISPLAY: dict[str, bool] = { + "show_nav_strategy": True, + "show_nav_strategy_records": True, + "show_nav_records": True, + "show_nav_stats": True, + "show_nav_risk_policy": True, + "show_nav_env_config": True, + "show_nav_options": True, + "show_settings_transfer": True, + "show_settings_export": True, + "show_settings_password": True, + "show_settings_options_swap": True, + "show_settings_options_transfer": True, +} + +DISPLAY_LABELS: dict[str, str] = { + "show_nav_strategy": "策略交易", + "show_nav_strategy_records": "策略交易记录", + "show_nav_records": "交易记录与复盘", + "show_nav_stats": "统计分析", + "show_nav_risk_policy": "风控说明", + "show_nav_env_config": "env配置", + "show_nav_options": "期权", + "show_settings_transfer": "资金划转", + "show_settings_export": "数据导出", + "show_settings_password": "账户密码修改", + "show_settings_options_swap": "期权币种兑换", + "show_settings_options_transfer": "期权资金划转", +} + +NAV_TAB_ALLOWED: dict[str, str] = { + "strategy": "show_nav_strategy", + "strategy_records": "show_nav_strategy_records", + "records": "show_nav_records", + "stats": "show_nav_stats", + "risk_policy": "show_nav_risk_policy", + "env_config": "show_nav_env_config", + "options": "show_nav_options", +} + + +def normalize_display_prefs(raw: dict | None) -> dict[str, bool]: + out = dict(DEFAULT_INSTANCE_DISPLAY) + if isinstance(raw, dict): + for key in DEFAULT_INSTANCE_DISPLAY: + if key in raw: + out[key] = bool(raw[key]) + return out + + +def _load_from_conn(conn) -> dict[str, bool]: + stored = runtime_get_prefix(conn, DISPLAY_RUNTIME_PREFIX) + merged: dict[str, Any] = {} + for key in DEFAULT_INSTANCE_DISPLAY: + sk = key + if sk in stored: + merged[key] = stored[sk].strip().lower() in ("1", "true", "yes", "on") + return normalize_display_prefs(merged) + + +def get_display_prefs(get_db: Callable) -> dict[str, bool]: + return with_db(get_db, _load_from_conn) + + +def save_display_prefs(get_db: Callable, prefs: dict) -> dict[str, bool]: + normalized = normalize_display_prefs(prefs) + + def _save(conn): + mapping = {DISPLAY_RUNTIME_PREFIX + k: ("1" if v else "0") for k, v in normalized.items()} + runtime_set_many(conn, mapping) + return normalized + + return with_db(get_db, _save) + + +def display_prefs_template_context(get_db: Callable) -> dict[str, Any]: + prefs = get_display_prefs(get_db) + return {"display": prefs} + + +def tab_allowed(tab: str, display: Optional[dict[str, bool]] = None) -> bool: + prefs = normalize_display_prefs(display or {}) + key = NAV_TAB_ALLOWED.get((tab or "").strip()) + if not key: + return True + return bool(prefs.get(key, True)) + + +def display_meta_for_ui() -> list[dict[str, Any]]: + nav_keys = [ + "show_nav_strategy", + "show_nav_strategy_records", + "show_nav_records", + "show_nav_stats", + "show_nav_risk_policy", + "show_nav_env_config", + "show_nav_options", + ] + settings_keys = [ + "show_settings_transfer", + "show_settings_export", + "show_settings_password", + "show_settings_options_swap", + "show_settings_options_transfer", + ] + return [ + {"group": "顶栏导航", "keys": [{"key": k, "label": DISPLAY_LABELS[k]} for k in nav_keys]}, + {"group": "系统设置区块", "keys": [{"key": k, "label": DISPLAY_LABELS[k]} for k in settings_keys]}, + ] diff --git a/lib/instance/instance_embed_context_lib.py b/lib/instance/instance_embed_context_lib.py index f7d7463..3cd6bc9 100644 --- a/lib/instance/instance_embed_context_lib.py +++ b/lib/instance/instance_embed_context_lib.py @@ -38,7 +38,7 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan: ) is_shell = embed_mode == "shell" is_strategy = page in EMBED_STRATEGY_PAGES - is_settings_like = page in ("settings", "risk_policy") + is_settings_like = page in ("settings", "risk_policy", "env_config") return EmbedRenderPlan( exchange_capitals=is_shell, records_rows=page == "records", diff --git a/lib/instance/instance_embed_lib.py b/lib/instance/instance_embed_lib.py index 29fe341..6504a5a 100644 --- a/lib/instance/instance_embed_lib.py +++ b/lib/instance/instance_embed_lib.py @@ -19,6 +19,7 @@ EMBED_TABS: tuple[str, ...] = ( "records", "stats", "risk_policy", + "env_config", "settings", ) @@ -34,6 +35,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = { "/records": "records", "/stats": "stats", "/risk_policy": "risk_policy", + "/env_config": "env_config", "/settings": "settings", } @@ -166,6 +168,9 @@ def register_embed_routes( tab = (tab or "").strip() if tab not in EMBED_TABS: return jsonify({"ok": False, "msg": "unknown tab"}), 404 + allowed_fn = app.config.get("INSTANCE_TAB_ALLOWED_FN") + if callable(allowed_fn) and not allowed_fn(tab): + return jsonify({"ok": False, "msg": "tab disabled"}), 403 html = render_main_page_fn(tab, embed_mode="fragment") if isinstance(html, Response): html = html.get_data(as_text=True) diff --git a/lib/instance/instance_pm2_lib.py b/lib/instance/instance_pm2_lib.py new file mode 100644 index 0000000..1b73ae9 --- /dev/null +++ b/lib/instance/instance_pm2_lib.py @@ -0,0 +1,49 @@ +"""PM2 重启当前实例(仅 Linux 部署环境)。""" +from __future__ import annotations + +import os +import subprocess +import sys +from typing import Any + + +def default_pm2_app_name(exchange_key: str) -> str: + mapping = { + "okx": "crypto_okx", + "binance": "crypto_binance", + "gate": "crypto_gate", + } + return mapping.get((exchange_key or "").strip().lower(), "crypto_okx") + + +def resolve_pm2_app_name(exchange_key: str) -> str: + explicit = (os.getenv("PM2_APP_NAME") or "").strip() + if explicit: + return explicit + return default_pm2_app_name(exchange_key) + + +def restart_instance_pm2(exchange_key: str) -> dict[str, Any]: + if not sys.platform.startswith("linux"): + return {"ok": False, "msg": "仅 Linux 服务器支持 PM2 重启", "app": None} + app_name = resolve_pm2_app_name(exchange_key) + try: + proc = subprocess.run( + ["pm2", "restart", app_name, "--update-env"], + capture_output=True, + text=True, + timeout=120, + ) + ok = proc.returncode == 0 + return { + "ok": ok, + "app": app_name, + "msg": (proc.stdout or proc.stderr or "").strip()[:500], + "returncode": proc.returncode, + } + except FileNotFoundError: + return {"ok": False, "msg": "未找到 pm2 命令", "app": app_name} + except subprocess.TimeoutExpired: + return {"ok": False, "msg": "pm2 restart 超时", "app": app_name} + except Exception as e: + return {"ok": False, "msg": str(e), "app": app_name} diff --git a/lib/instance/instance_settings_lib.py b/lib/instance/instance_settings_lib.py index c784887..cacbd65 100644 --- a/lib/instance/instance_settings_lib.py +++ b/lib/instance/instance_settings_lib.py @@ -184,6 +184,7 @@ def build_instance_settings_view( def settings_page_context(page: str, **kwargs: Any) -> dict[str, Any]: - if (page or "").strip() not in ("settings", "risk_policy"): + p = (page or "").strip() + if p not in ("settings", "risk_policy", "env_config"): return {} return {"instance_settings": build_instance_settings_view(**kwargs)} diff --git a/lib/instance/instance_settings_register.py b/lib/instance/instance_settings_register.py new file mode 100644 index 0000000..02a72f4 --- /dev/null +++ b/lib/instance/instance_settings_register.py @@ -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/", 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 diff --git a/lib/instance/runtime_config_lib.py b/lib/instance/runtime_config_lib.py new file mode 100644 index 0000000..7db8ee6 --- /dev/null +++ b/lib/instance/runtime_config_lib.py @@ -0,0 +1,62 @@ +"""env 运行时覆盖:热生效项优先读 SQLite,再回退 os.environ。""" +from __future__ import annotations + +import os +from typing import Callable, Optional + +from lib.env.env_file_lib import load_env_file_into_environ +from lib.instance.runtime_settings_lib import runtime_get, with_db + +ENV_OVERRIDE_PREFIX = "env." + + +def runtime_env_key(name: str) -> str: + return ENV_OVERRIDE_PREFIX + name + + +def get_config(key: str, get_db: Callable, default: Optional[str] = None) -> Optional[str]: + def _read(conn): + v = runtime_get(conn, runtime_env_key(key)) + return v + + try: + v = with_db(get_db, _read) + if v is not None: + return v + except Exception: + pass + raw = os.getenv(key) + if raw is None or raw == "": + return default + return raw + + +def set_config_overrides(get_db: Callable, mapping: dict[str, str]) -> None: + from lib.instance.runtime_settings_lib import runtime_set_many + + def _write(conn): + payload = {runtime_env_key(k): str(v) for k, v in mapping.items()} + runtime_set_many(conn, payload) + + with_db(get_db, _write) + + +def apply_env_reload(env_path: str, get_db: Callable, changed_keys: list[str], groups: list[dict]) -> dict[str, bool]: + """写盘后同步 os.environ,并将可热生效项写入 runtime 覆盖。""" + load_env_file_into_environ(env_path) + hot: dict[str, str] = {} + 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("hot_reload") and not meta.get("restart_required"): + val = os.getenv(key) + if val is not None: + hot[key] = val + if hot: + set_config_overrides(get_db, hot) + from lib.env.env_schema import updates_need_restart + + return {"restart_required": updates_need_restart(groups, changed_keys)} diff --git a/lib/instance/runtime_settings_lib.py b/lib/instance/runtime_settings_lib.py new file mode 100644 index 0000000..fd6415f --- /dev/null +++ b/lib/instance/runtime_settings_lib.py @@ -0,0 +1,71 @@ +"""实例 SQLite 运行时配置(导航开关、env 热覆盖等)。""" +from __future__ import annotations + +import sqlite3 +from datetime import datetime +from typing import Any, Callable, Optional + +RUNTIME_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS app_runtime_settings ( + key TEXT PRIMARY KEY, + value TEXT, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +) +""" + + +def ensure_runtime_settings_table(conn: sqlite3.Connection) -> None: + conn.execute(RUNTIME_TABLE_SQL) + conn.commit() + + +def runtime_get(conn: sqlite3.Connection, key: str) -> Optional[str]: + row = conn.execute( + "SELECT value FROM app_runtime_settings WHERE key=?", + (key,), + ).fetchone() + if not row: + return None + val = row["value"] if isinstance(row, sqlite3.Row) else row[0] + return None if val is None else str(val) + + +def runtime_set(conn: sqlite3.Connection, key: str, value: str) -> None: + now = datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + conn.execute( + "INSERT INTO app_runtime_settings(key, value, updated_at) VALUES (?,?,?) " + "ON CONFLICT(key) DO UPDATE SET value=excluded.value, updated_at=excluded.updated_at", + (key, value, now), + ) + conn.commit() + + +def runtime_get_prefix(conn: sqlite3.Connection, prefix: str) -> dict[str, str]: + rows = conn.execute( + "SELECT key, value FROM app_runtime_settings WHERE key LIKE ?", + (prefix + "%",), + ).fetchall() + out: dict[str, str] = {} + for row in rows: + k = row["key"] if isinstance(row, sqlite3.Row) else row[0] + v = row["value"] if isinstance(row, sqlite3.Row) else row[1] + if k.startswith(prefix): + out[k[len(prefix) :]] = v if v is not None else "" + return out + + +def runtime_set_many(conn: sqlite3.Connection, mapping: dict[str, str]) -> None: + for key, value in mapping.items(): + runtime_set(conn, key, value) + + +def with_db( + get_db: Callable[[], sqlite3.Connection], + fn: Callable[[sqlite3.Connection], Any], +) -> Any: + conn = get_db() + try: + ensure_runtime_settings_table(conn) + return fn(conn) + finally: + conn.close() diff --git a/lib/instance/templates/display_prefs_panel.html b/lib/instance/templates/display_prefs_panel.html new file mode 100644 index 0000000..43dae42 --- /dev/null +++ b/lib/instance/templates/display_prefs_panel.html @@ -0,0 +1,12 @@ +{# 系统设置 · 导航显示开关 #} +
+

导航显示

+

以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效。关键位监控、实盘下单、系统设置为固定项。

+
+
加载中…
+
+
+ + +
+
diff --git a/lib/instance/templates/embed_page_fragment.html b/lib/instance/templates/embed_page_fragment.html index 5d5624b..1264da4 100644 --- a/lib/instance/templates/embed_page_fragment.html +++ b/lib/instance/templates/embed_page_fragment.html @@ -396,6 +396,9 @@ {% endif %} + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} {% if page == 'risk_policy' %} {% include 'risk_policy_panel.html' %} {% endif %} diff --git a/lib/instance/templates/embed_shell.html b/lib/instance/templates/embed_shell.html index 21a2c71..179de05 100644 --- a/lib/instance/templates/embed_shell.html +++ b/lib/instance/templates/embed_shell.html @@ -7,7 +7,7 @@ - + {{ exchange_display }} · 加密货币 | 交易监控复盘系统 @@ -30,22 +30,33 @@ {% include 'instance_header_panel.html' %} - {% if initial_tab not in ('settings', 'risk_policy') and include_transfer_block %} + {% if initial_tab not in ('settings', 'risk_policy', 'env_config') and include_transfer_block %} {% include 'instance_top_bar.html' %} {% endif %} @@ -89,7 +100,11 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj {% include 'embed_boot_scripts.html' %} - - + + + + diff --git a/lib/instance/templates/env_config_panel.html b/lib/instance/templates/env_config_panel.html new file mode 100644 index 0000000..adec2f9 --- /dev/null +++ b/lib/instance/templates/env_config_panel.html @@ -0,0 +1,16 @@ +{# env配置:按功能分组,三列卡片布局 #} +
+
+

env 配置

+

读取并编辑本实例 .env。标注「保存即生效」的项会立即应用;标注「需重启」的项保存后请点「保存并重启」。

+
+ + + + +
+
+
+
加载配置中…
+
+
diff --git a/lib/instance/templates/index.html b/lib/instance/templates/index.html index 1428715..ab991c2 100644 --- a/lib/instance/templates/index.html +++ b/lib/instance/templates/index.html @@ -17,7 +17,7 @@ {{ exchange_display }} · 加密货币 | 交易监控复盘系统 - + 关键位监控 实盘下单 - {% if not intraday_discipline %} + {% if not intraday_discipline and display.show_nav_strategy %} 策略交易 + {% endif %} + {% if not intraday_discipline and display.show_nav_strategy_records %} 策略交易记录 {% endif %} + {% if display.show_nav_records %} 交易记录与复盘 + {% endif %} + {% if display.show_nav_stats %} 统计分析 - {% if options_nav_visible %} + {% endif %} + {% if options_nav_visible and display.show_nav_options %} 期权 {% endif %} + {% if display.show_nav_risk_policy %} 风控说明 + {% endif %} + {% if display.show_nav_env_config %} + env配置 + {% endif %} 系统设置 {% with msg=get_flashed_messages() %}{% if msg %}
{{ msg[0] }}
{% endif %}{% endwith %} {% include 'instance_header_panel.html' %} - {% if page not in ('settings', 'risk_policy', 'options') %} + {% if page not in ('settings', 'risk_policy', 'env_config', 'options') %} {% include 'instance_top_bar.html' %} {% endif %} @@ -468,6 +479,10 @@ {% endif %} + {% if page == 'env_config' %} + {% include 'env_config_panel.html' %} + {% endif %} + {% if page == 'risk_policy' %} {% include 'risk_policy_panel.html' %} {% endif %} @@ -2020,5 +2035,9 @@ setInterval(tickOrderHoldDurations, 1000); tickOrderHoldDurations(); setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }}); + + \ No newline at end of file diff --git a/lib/instance/templates/password_settings_panel.html b/lib/instance/templates/password_settings_panel.html new file mode 100644 index 0000000..a1cc47e --- /dev/null +++ b/lib/instance/templates/password_settings_panel.html @@ -0,0 +1,15 @@ +{# 系统设置 · 账户密码 #} +
+

账户密码修改

+

修改网页登录账号密码,写入 .env 后需重启实例生效。

+
+ + + + +
+
+ + +
+
diff --git a/lib/instance/templates/settings_panel.html b/lib/instance/templates/settings_panel.html index 0982c98..c73dde6 100644 --- a/lib/instance/templates/settings_panel.html +++ b/lib/instance/templates/settings_panel.html @@ -1,19 +1,29 @@ -{# 系统设置:资金划转 + 数据导出(顶栏资金与统计见 instance_header_panel) #} +{# 系统设置:导航显示 + 账户密码 + 资金划转 + 数据导出 #}
+ {% include 'display_prefs_panel.html' %} + + {% if display.show_settings_password %} + {% include 'password_settings_panel.html' %} + {% endif %} +
- {% if instance_settings.options_settings_enabled %} + {% if instance_settings.options_settings_enabled and display.show_settings_options_swap %}

币种兑换

{% include 'options_settings_swap.html' %}
+ {% endif %} + {% if instance_settings.options_settings_enabled and display.show_settings_options_transfer %}

期权资金划转

{% include 'options_settings_transfer.html' %}
+ {% endif %} + {% if instance_settings.options_settings_enabled %} {% include 'options_settings_panel.html' %} {% endif %} - {% if instance_settings.show_transfer %} + {% if instance_settings.show_transfer and display.show_settings_transfer %}

永续资金划转

子账户永续:资金账户与交易账户之间划转 USDT。

@@ -21,6 +31,7 @@
{% endif %} + {% if display.show_settings_export %}
数据导出 @@ -33,6 +44,7 @@ 关键位历史
+ {% endif %}
diff --git a/tests/test_instance_display_env_settings.py b/tests/test_instance_display_env_settings.py new file mode 100644 index 0000000..1a5c263 --- /dev/null +++ b/tests/test_instance_display_env_settings.py @@ -0,0 +1,57 @@ +"""instance_display_prefs_lib 与 env_file_lib 单元测试。""" +from __future__ import annotations + +import os +import tempfile +import unittest + +from lib.env.env_file_lib import apply_env_updates, env_get, read_env_lines +from lib.env.env_schema import parse_env_example_schema, validate_env_updates +from lib.instance.instance_display_prefs_lib import normalize_display_prefs, tab_allowed + + +class TestInstanceDisplayPrefs(unittest.TestCase): + def test_normalize_defaults_all_on(self): + prefs = normalize_display_prefs({}) + self.assertTrue(prefs["show_nav_env_config"]) + self.assertTrue(prefs["show_settings_password"]) + + def test_tab_allowed_respects_prefs(self): + prefs = normalize_display_prefs({"show_nav_stats": False}) + self.assertFalse(tab_allowed("stats", prefs)) + self.assertTrue(tab_allowed("trade", prefs)) + + +class TestEnvFileLib(unittest.TestCase): + def test_upsert_and_read(self): + with tempfile.TemporaryDirectory() as td: + path = os.path.join(td, ".env") + with open(path, "w", encoding="utf-8") as f: + f.write("FOO=1\n") + changed = apply_env_updates(path, {"FOO": "2", "BAR": "x"}) + self.assertIn("FOO", changed) + self.assertIn("BAR", changed) + lines = read_env_lines(path) + self.assertEqual(env_get(lines, "FOO"), "2") + self.assertEqual(env_get(lines, "BAR"), "x") + + +class TestEnvSchema(unittest.TestCase): + def test_parse_okx_example(self): + root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + example = os.path.join(root, "crypto_monitor_okx", ".env.example") + if not os.path.isfile(example): + self.skipTest("missing okx .env.example") + groups = parse_env_example_schema(example) + keys = [f["key"] for g in groups for f in g.get("fields", [])] + self.assertIn("OKX_API_KEY", keys) + self.assertIn("MAX_ACTIVE_POSITIONS", keys) + + def test_validate_unknown_key(self): + groups = [{"title": "t", "fields": [{"key": "A", "type": "text", "sensitive": False}]}] + clean, errors = validate_env_updates(groups, {"B": "1"}) + self.assertTrue(errors) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_instance_embed_lib.py b/tests/test_instance_embed_lib.py index ef6b4af..9ec5eab 100644 --- a/tests/test_instance_embed_lib.py +++ b/tests/test_instance_embed_lib.py @@ -28,8 +28,10 @@ def test_embed_tabs_cover_main_nav(): assert "trade" in EMBED_TABS assert "key_monitor" in EMBED_TABS assert "records" in EMBED_TABS + assert "env_config" in EMBED_TABS assert "risk_policy" in EMBED_TABS assert "settings" in EMBED_TABS + assert path_to_embed_tab("/env_config") == "env_config" assert path_to_embed_tab("/risk_policy") == "risk_policy" assert path_to_embed_tab("/settings") == "settings"