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:
@@ -1235,6 +1235,11 @@ def init_db():
|
|||||||
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
|
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
|
||||||
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
|
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
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('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''')
|
||||||
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
|
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
|
||||||
ON transfer_logs(transfer_type, transfer_day)
|
ON transfer_logs(transfer_type, transfer_day)
|
||||||
@@ -7252,6 +7257,7 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
conn.close()
|
conn.close()
|
||||||
from lib.instance.instance_embed_lib import embed_context_extras
|
from lib.instance.instance_embed_lib import embed_context_extras
|
||||||
from lib.instance.instance_settings_lib import settings_page_context
|
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(
|
template_ctx = dict(
|
||||||
page=page,
|
page=page,
|
||||||
@@ -7334,6 +7340,7 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
kline_timeframe=KLINE_TIMEFRAME,
|
kline_timeframe=KLINE_TIMEFRAME,
|
||||||
**strategy_extra,
|
**strategy_extra,
|
||||||
**embed_context_extras("binance"),
|
**embed_context_extras("binance"),
|
||||||
|
**display_prefs_template_context(get_db),
|
||||||
**settings_page_context(
|
**settings_page_context(
|
||||||
page,
|
page,
|
||||||
exchange_key="binance",
|
exchange_key="binance",
|
||||||
@@ -7391,6 +7398,12 @@ def risk_policy_page():
|
|||||||
return render_main_page("risk_policy")
|
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")
|
@app.route("/settings")
|
||||||
@login_required
|
@login_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
@@ -9883,6 +9896,21 @@ try:
|
|||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] binance: {_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")
|
@app.route("/strategy")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -1226,6 +1226,11 @@ def init_db():
|
|||||||
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
|
(id INTEGER PRIMARY KEY AUTOINCREMENT, transfer_type TEXT, transfer_day TEXT,
|
||||||
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
|
amount REAL, from_account TEXT, to_account TEXT, status TEXT, message TEXT,
|
||||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)''')
|
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('''DROP INDEX IF EXISTS idx_transfer_logs_unique_day''')
|
||||||
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
|
c.execute('''CREATE UNIQUE INDEX IF NOT EXISTS idx_transfer_logs_auto_daily_unique
|
||||||
ON transfer_logs(transfer_type, transfer_day)
|
ON transfer_logs(transfer_type, transfer_day)
|
||||||
@@ -7046,6 +7051,7 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
conn.close()
|
conn.close()
|
||||||
from lib.instance.instance_embed_lib import embed_context_extras
|
from lib.instance.instance_embed_lib import embed_context_extras
|
||||||
from lib.instance.instance_settings_lib import settings_page_context
|
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(
|
template_ctx = dict(
|
||||||
page=page,
|
page=page,
|
||||||
@@ -7137,6 +7143,7 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
has_active_positions=bool(order_list),
|
has_active_positions=bool(order_list),
|
||||||
),
|
),
|
||||||
**embed_context_extras("gate"),
|
**embed_context_extras("gate"),
|
||||||
|
**display_prefs_template_context(get_db),
|
||||||
**settings_page_context(
|
**settings_page_context(
|
||||||
page,
|
page,
|
||||||
exchange_key="gate",
|
exchange_key="gate",
|
||||||
@@ -7206,6 +7213,12 @@ def risk_policy_page():
|
|||||||
return render_main_page("risk_policy")
|
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")
|
@app.route("/settings")
|
||||||
@login_required
|
@login_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
@@ -9758,6 +9771,21 @@ try:
|
|||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] gate: {_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")
|
@app.route("/strategy")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -6645,6 +6645,7 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
conn.close()
|
conn.close()
|
||||||
from lib.instance.instance_embed_lib import embed_context_extras
|
from lib.instance.instance_embed_lib import embed_context_extras
|
||||||
from lib.instance.instance_settings_lib import settings_page_context
|
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(
|
template_ctx = dict(
|
||||||
page=page,
|
page=page,
|
||||||
@@ -6743,6 +6744,7 @@ def render_main_page(page="trade", embed_mode=None):
|
|||||||
exchange_pnl_sync=exchange_pnl_sync,
|
exchange_pnl_sync=exchange_pnl_sync,
|
||||||
**strategy_extra,
|
**strategy_extra,
|
||||||
**embed_context_extras("okx"),
|
**embed_context_extras("okx"),
|
||||||
|
**display_prefs_template_context(get_db),
|
||||||
**settings_page_context(
|
**settings_page_context(
|
||||||
page,
|
page,
|
||||||
exchange_key="okx",
|
exchange_key="okx",
|
||||||
@@ -6828,6 +6830,15 @@ def risk_policy_page():
|
|||||||
return render_main_page("risk_policy")
|
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")
|
@app.route("/settings")
|
||||||
@login_required
|
@login_required
|
||||||
def settings_page():
|
def settings_page():
|
||||||
@@ -9432,6 +9443,21 @@ try:
|
|||||||
except Exception as _hub_err:
|
except Exception as _hub_err:
|
||||||
print(f"[hub_bridge] okx: {_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")
|
@app.route("/strategy")
|
||||||
@login_required
|
@login_required
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
records: "/records",
|
records: "/records",
|
||||||
stats: "/stats",
|
stats: "/stats",
|
||||||
risk_policy: "/risk_policy",
|
risk_policy: "/risk_policy",
|
||||||
|
env_config: "/env_config",
|
||||||
settings: "/settings",
|
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) {
|
function syncUrl(tab, replace) {
|
||||||
const q = new URLSearchParams(location.search);
|
const q = new URLSearchParams(location.search);
|
||||||
q.set("tab", tab);
|
q.set("tab", tab);
|
||||||
@@ -99,6 +107,16 @@
|
|||||||
if (!revisit && tab === "stats") {
|
if (!revisit && tab === "stats") {
|
||||||
if (typeof global.initStatsSegmentFromUrl === "function") global.initStatsSegmentFromUrl();
|
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 (!revisit) {
|
||||||
if (typeof global.refreshAccountSnapshot === "function") {
|
if (typeof global.refreshAccountSnapshot === "function") {
|
||||||
global.refreshAccountSnapshot({ silent: true });
|
global.refreshAccountSnapshot({ silent: true });
|
||||||
@@ -243,7 +261,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncShellChrome(tab) {
|
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) => {
|
document.querySelectorAll(".instance-top-bar").forEach((el) => {
|
||||||
el.hidden = hideTopBar;
|
el.hidden = hideTopBar;
|
||||||
});
|
});
|
||||||
@@ -286,6 +304,10 @@
|
|||||||
async function loadTab(tab, opts) {
|
async function loadTab(tab, opts) {
|
||||||
const options = opts || {};
|
const options = opts || {};
|
||||||
if (!tab) return;
|
if (!tab) return;
|
||||||
|
if (!pageNavAllowed(tab)) {
|
||||||
|
void loadTab("trade", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (tabPanes.has(tab) && !options.force) {
|
if (tabPanes.has(tab) && !options.force) {
|
||||||
activateTab(tab, Object.assign({}, options, { revisit: true }));
|
activateTab(tab, Object.assign({}, options, { revisit: true }));
|
||||||
@@ -434,7 +456,12 @@
|
|||||||
patchApplyListWindow();
|
patchApplyListWindow();
|
||||||
patchHardNavigations();
|
patchHardNavigations();
|
||||||
initBootPane();
|
initBootPane();
|
||||||
if (getTab() === "settings") {
|
const bootTab = getTab();
|
||||||
|
if (!pageNavAllowed(bootTab)) {
|
||||||
|
void loadTab("trade", { replace: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (bootTab === "settings") {
|
||||||
initPaneThemeToggle("settings");
|
initPaneThemeToggle("settings");
|
||||||
}
|
}
|
||||||
bindNav();
|
bindNav();
|
||||||
|
|||||||
@@ -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 = '<span class="err">' + (e.message || "加载失败") + "</span>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '<div class="env-config-loading muted">加载配置中…</div>';
|
||||||
|
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 = '<span class="err">' + (e.message || "加载失败") + "</span>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
@@ -2181,6 +2181,153 @@ html[data-theme="light"] .journal-detail-img-thumb {
|
|||||||
background: #eef2f7;
|
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 {
|
.risk-policy-page {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
|
|||||||
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
|
||||||
@@ -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]},
|
||||||
|
]
|
||||||
@@ -38,7 +38,7 @@ def embed_render_plan(page: str, embed_mode: str | None) -> EmbedRenderPlan:
|
|||||||
)
|
)
|
||||||
is_shell = embed_mode == "shell"
|
is_shell = embed_mode == "shell"
|
||||||
is_strategy = page in EMBED_STRATEGY_PAGES
|
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(
|
return EmbedRenderPlan(
|
||||||
exchange_capitals=is_shell,
|
exchange_capitals=is_shell,
|
||||||
records_rows=page == "records",
|
records_rows=page == "records",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ EMBED_TABS: tuple[str, ...] = (
|
|||||||
"records",
|
"records",
|
||||||
"stats",
|
"stats",
|
||||||
"risk_policy",
|
"risk_policy",
|
||||||
|
"env_config",
|
||||||
"settings",
|
"settings",
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ PATH_TO_EMBED_TAB: dict[str, str] = {
|
|||||||
"/records": "records",
|
"/records": "records",
|
||||||
"/stats": "stats",
|
"/stats": "stats",
|
||||||
"/risk_policy": "risk_policy",
|
"/risk_policy": "risk_policy",
|
||||||
|
"/env_config": "env_config",
|
||||||
"/settings": "settings",
|
"/settings": "settings",
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,6 +168,9 @@ def register_embed_routes(
|
|||||||
tab = (tab or "").strip()
|
tab = (tab or "").strip()
|
||||||
if tab not in EMBED_TABS:
|
if tab not in EMBED_TABS:
|
||||||
return jsonify({"ok": False, "msg": "unknown tab"}), 404
|
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")
|
html = render_main_page_fn(tab, embed_mode="fragment")
|
||||||
if isinstance(html, Response):
|
if isinstance(html, Response):
|
||||||
html = html.get_data(as_text=True)
|
html = html.get_data(as_text=True)
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -184,6 +184,7 @@ def build_instance_settings_view(
|
|||||||
|
|
||||||
|
|
||||||
def settings_page_context(page: str, **kwargs: Any) -> dict[str, Any]:
|
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 {}
|
||||||
return {"instance_settings": build_instance_settings_view(**kwargs)}
|
return {"instance_settings": build_instance_settings_view(**kwargs)}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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)}
|
||||||
@@ -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()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{# 系统设置 · 导航显示开关 #}
|
||||||
|
<div class="card settings-card settings-card--compact" id="display-prefs-card">
|
||||||
|
<h2>导航显示</h2>
|
||||||
|
<p class="settings-env-hint">以下开关控制顶栏导航与系统设置内区块是否显示,保存后立即生效。关键位监控、实盘下单、系统设置为固定项。</p>
|
||||||
|
<div id="display-prefs-form" class="display-prefs-form">
|
||||||
|
<div class="display-prefs-loading muted">加载中…</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-actions-row">
|
||||||
|
<button type="button" class="btn-primary btn-sm" id="display-prefs-save">保存导航设置</button>
|
||||||
|
<span class="settings-status-line" id="display-prefs-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -396,6 +396,9 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if page == 'env_config' %}
|
||||||
|
{% include 'env_config_panel.html' %}
|
||||||
|
{% endif %}
|
||||||
{% if page == 'risk_policy' %}
|
{% if page == 'risk_policy' %}
|
||||||
{% include 'risk_policy_panel.html' %}
|
{% include 'risk_policy_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
<link rel="stylesheet" href="/static/instance_theme_early.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
<link rel="stylesheet" href="/static/account_risk_badge.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=4">
|
<link rel="stylesheet" href="/static/instance_page.css?v=4">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=70">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=71">
|
||||||
<script src="/static/account_risk_badge.js?v=4"></script>
|
<script src="/static/account_risk_badge.js?v=4"></script>
|
||||||
<meta name="theme-color" content="#0b0d14">
|
<meta name="theme-color" content="#0b0d14">
|
||||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||||
@@ -30,22 +30,33 @@
|
|||||||
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
<nav class="top-nav embed-top-nav" aria-label="实例导航">
|
||||||
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
<a href="/key_monitor" data-embed-tab="key_monitor" class="{% if initial_tab == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
||||||
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}">实盘下单</a>
|
<a href="/trade" data-embed-tab="trade" class="{% if initial_tab == 'trade' %}active{% endif %}">实盘下单</a>
|
||||||
{% if not intraday_discipline %}
|
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||||
<a href="/strategy" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
|
<a href="/strategy" data-embed-tab="strategy" class="{% if initial_tab == 'strategy' %}active{% endif %}">策略交易</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if not intraday_discipline and display.show_nav_strategy_records %}
|
||||||
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if initial_tab == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
<a href="/strategy/records" data-embed-tab="strategy_records" class="{% if initial_tab == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if display.show_nav_records %}
|
||||||
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
|
<a href="/records" data-embed-tab="records" class="{% if initial_tab == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if display.show_nav_stats %}
|
||||||
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
|
<a href="/stats" data-embed-tab="stats" class="{% if initial_tab == 'stats' %}active{% endif %}">统计分析</a>
|
||||||
{% if options_nav_visible %}
|
{% endif %}
|
||||||
|
{% if options_nav_visible and display.show_nav_options %}
|
||||||
<a href="/options" data-embed-tab="options" class="{% if initial_tab == 'options' %}active{% endif %}">期权</a>
|
<a href="/options" data-embed-tab="options" class="{% if initial_tab == 'options' %}active{% endif %}">期权</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if display.show_nav_risk_policy %}
|
||||||
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
|
<a href="/risk_policy" data-embed-tab="risk_policy" class="{% if initial_tab == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if display.show_nav_env_config %}
|
||||||
|
<a href="/env_config" data-embed-tab="env_config" class="{% if initial_tab == 'env_config' %}active{% endif %}">env配置</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
<a href="/settings" data-embed-tab="settings" class="{% if initial_tab == 'settings' %}active{% endif %}">系统设置</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
<div id="embed-flash" class="flash" style="display:none" role="status"></div>
|
||||||
|
|
||||||
{% include 'instance_header_panel.html' %}
|
{% 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' %}
|
{% include 'instance_top_bar.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -89,7 +100,11 @@ const ORDER_ENTRY_MODEL_CODE_TO_CATEGORY = {{ entry_model_code_to_category | toj
|
|||||||
<script src="/static/strategy_roll.js?v=6"></script>
|
<script src="/static/strategy_roll.js?v=6"></script>
|
||||||
<script src="/static/key_monitor_form.js?v=2"></script>
|
<script src="/static/key_monitor_form.js?v=2"></script>
|
||||||
{% include 'embed_boot_scripts.html' %}
|
{% include 'embed_boot_scripts.html' %}
|
||||||
<script src="/static/instance_live.js?v=3"></script>
|
<script>
|
||||||
<script src="/static/instance_embed.js?v=16"></script>
|
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||||
|
</script>
|
||||||
|
<script src="/static/instance_settings_prefs.js?v=1"></script>
|
||||||
|
<script src="/static/instance_live.js?v=4"></script>
|
||||||
|
<script src="/static/instance_embed.js?v=17"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{# env配置:按功能分组,三列卡片布局 #}
|
||||||
|
<div class="env-config-page">
|
||||||
|
<div class="env-config-head card">
|
||||||
|
<h2>env 配置</h2>
|
||||||
|
<p class="settings-env-hint">读取并编辑本实例 <code>.env</code>。标注「保存即生效」的项会立即应用;标注「需重启」的项保存后请点「保存并重启」。</p>
|
||||||
|
<div class="env-config-toolbar">
|
||||||
|
<button type="button" class="btn-primary btn-sm" id="env-config-save">保存</button>
|
||||||
|
<button type="button" class="btn-secondary btn-sm" id="env-config-save-restart">保存并重启</button>
|
||||||
|
<button type="button" class="btn-secondary btn-sm" id="env-config-reload">重新加载</button>
|
||||||
|
<span class="settings-status-line" id="env-config-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="env-config-grid" class="env-config-grid">
|
||||||
|
<div class="env-config-loading muted">加载配置中…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
<link rel="manifest" href="/static/icons/manifest.webmanifest">
|
||||||
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
<title>{{ exchange_display }} · 加密货币 | 交易监控复盘系统</title>
|
||||||
<link rel="stylesheet" href="/static/instance_page.css?v=1">
|
<link rel="stylesheet" href="/static/instance_page.css?v=1">
|
||||||
<link rel="stylesheet" href="/static/instance_theme.css?v=70">
|
<link rel="stylesheet" href="/static/instance_theme.css?v=71">
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
@@ -55,22 +55,33 @@
|
|||||||
<div class="top-nav">
|
<div class="top-nav">
|
||||||
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
<a href="/key_monitor" class="{% if page == 'key_monitor' %}active{% endif %}">关键位监控</a>
|
||||||
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
|
<a href="/trade" class="{% if page == 'trade' %}active{% endif %}">实盘下单</a>
|
||||||
{% if not intraday_discipline %}
|
{% if not intraday_discipline and display.show_nav_strategy %}
|
||||||
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
|
<a href="/strategy" class="{% if page in ('strategy', 'strategy_trend', 'strategy_roll') %}active{% endif %}">策略交易</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if not intraday_discipline and display.show_nav_strategy_records %}
|
||||||
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
<a href="/strategy/records" class="{% if page == 'strategy_records' %}active{% endif %}">策略交易记录</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if display.show_nav_records %}
|
||||||
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
<a href="/records" class="{% if page == 'records' %}active{% endif %}">交易记录与复盘</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if display.show_nav_stats %}
|
||||||
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
<a href="/stats" class="{% if page == 'stats' %}active{% endif %}">统计分析</a>
|
||||||
{% if options_nav_visible %}
|
{% endif %}
|
||||||
|
{% if options_nav_visible and display.show_nav_options %}
|
||||||
<a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a>
|
<a href="/options" class="{% if page == 'options' %}active{% endif %}">期权</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{% if display.show_nav_risk_policy %}
|
||||||
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
|
<a href="/risk_policy" class="{% if page == 'risk_policy' %}active{% endif %}">风控说明</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if display.show_nav_env_config %}
|
||||||
|
<a href="/env_config" class="{% if page == 'env_config' %}active{% endif %}">env配置</a>
|
||||||
|
{% endif %}
|
||||||
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
<a href="/settings" class="{% if page == 'settings' %}active{% endif %}">系统设置</a>
|
||||||
</div>
|
</div>
|
||||||
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
{% with msg=get_flashed_messages() %}{% if msg %}<div class="flash">{{ msg[0] }}</div>{% endif %}{% endwith %}
|
||||||
|
|
||||||
{% include 'instance_header_panel.html' %}
|
{% 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' %}
|
{% include 'instance_top_bar.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
@@ -468,6 +479,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if page == 'env_config' %}
|
||||||
|
{% include 'env_config_panel.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if page == 'risk_policy' %}
|
{% if page == 'risk_policy' %}
|
||||||
{% include 'risk_policy_panel.html' %}
|
{% include 'risk_policy_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -2020,5 +2035,9 @@ setInterval(tickOrderHoldDurations, 1000);
|
|||||||
tickOrderHoldDurations();
|
tickOrderHoldDurations();
|
||||||
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
setInterval(refreshPriceSnapshotConditional, {{ price_refresh_seconds * 1000 }});
|
||||||
</script>
|
</script>
|
||||||
|
<script>
|
||||||
|
window.__INSTANCE_DISPLAY__ = {{ display | tojson }};
|
||||||
|
</script>
|
||||||
|
<script src="/static/instance_settings_prefs.js?v=1"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{# 系统设置 · 账户密码 #}
|
||||||
|
<div class="card settings-subcard settings-subcard--ops" id="password-settings-card" data-settings-section="password">
|
||||||
|
<h3 class="settings-subcard-title">账户密码修改</h3>
|
||||||
|
<p class="settings-subcard-desc">修改网页登录账号密码,写入 <code>.env</code> 后需重启实例生效。</p>
|
||||||
|
<div class="settings-password-form">
|
||||||
|
<label>当前密码 <input type="password" id="pwd-old" autocomplete="current-password"></label>
|
||||||
|
<label>新用户名(可选) <input type="text" id="pwd-new-username" autocomplete="username"></label>
|
||||||
|
<label>新密码 <input type="password" id="pwd-new" autocomplete="new-password"></label>
|
||||||
|
<label>确认新密码 <input type="password" id="pwd-confirm" autocomplete="new-password"></label>
|
||||||
|
</div>
|
||||||
|
<div class="settings-actions-row">
|
||||||
|
<button type="button" class="btn-primary btn-sm" id="pwd-save-btn">保存密码</button>
|
||||||
|
<span class="settings-status-line" id="pwd-save-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,19 +1,29 @@
|
|||||||
{# 系统设置:资金划转 + 数据导出(顶栏资金与统计见 instance_header_panel) #}
|
{# 系统设置:导航显示 + 账户密码 + 资金划转 + 数据导出 #}
|
||||||
<div class="settings-page settings-page--ops">
|
<div class="settings-page settings-page--ops">
|
||||||
|
{% include 'display_prefs_panel.html' %}
|
||||||
|
|
||||||
|
{% if display.show_settings_password %}
|
||||||
|
{% include 'password_settings_panel.html' %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="card settings-card settings-card--side-panel">
|
<div class="card settings-card settings-card--side-panel">
|
||||||
<div class="settings-side-subcards">
|
<div class="settings-side-subcards">
|
||||||
{% if instance_settings.options_settings_enabled %}
|
{% if instance_settings.options_settings_enabled and display.show_settings_options_swap %}
|
||||||
<div class="card settings-subcard settings-subcard--ops">
|
<div class="card settings-subcard settings-subcard--ops">
|
||||||
<h3 class="settings-subcard-title">币种兑换</h3>
|
<h3 class="settings-subcard-title">币种兑换</h3>
|
||||||
{% include 'options_settings_swap.html' %}
|
{% include 'options_settings_swap.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if instance_settings.options_settings_enabled and display.show_settings_options_transfer %}
|
||||||
<div class="card settings-subcard settings-subcard--ops">
|
<div class="card settings-subcard settings-subcard--ops">
|
||||||
<h3 class="settings-subcard-title">期权资金划转</h3>
|
<h3 class="settings-subcard-title">期权资金划转</h3>
|
||||||
{% include 'options_settings_transfer.html' %}
|
{% include 'options_settings_transfer.html' %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if instance_settings.options_settings_enabled %}
|
||||||
{% include 'options_settings_panel.html' %}
|
{% include 'options_settings_panel.html' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if instance_settings.show_transfer %}
|
{% if instance_settings.show_transfer and display.show_settings_transfer %}
|
||||||
<div class="card settings-subcard settings-subcard--ops">
|
<div class="card settings-subcard settings-subcard--ops">
|
||||||
<h3 class="settings-subcard-title">永续资金划转</h3>
|
<h3 class="settings-subcard-title">永续资金划转</h3>
|
||||||
<p class="settings-subcard-desc">子账户永续:资金账户与交易账户之间划转 USDT。</p>
|
<p class="settings-subcard-desc">子账户永续:资金账户与交易账户之间划转 USDT。</p>
|
||||||
@@ -21,6 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{% if display.show_settings_export %}
|
||||||
<div class="settings-side-export">
|
<div class="settings-side-export">
|
||||||
<div class="settings-side-export-head">
|
<div class="settings-side-export-head">
|
||||||
<span class="settings-side-export-label">数据导出</span>
|
<span class="settings-side-export-label">数据导出</span>
|
||||||
@@ -33,6 +44,7 @@
|
|||||||
<a href="/export/key_monitor_history">关键位历史</a>
|
<a href="/export/key_monitor_history">关键位历史</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -28,8 +28,10 @@ def test_embed_tabs_cover_main_nav():
|
|||||||
assert "trade" in EMBED_TABS
|
assert "trade" in EMBED_TABS
|
||||||
assert "key_monitor" in EMBED_TABS
|
assert "key_monitor" in EMBED_TABS
|
||||||
assert "records" in EMBED_TABS
|
assert "records" in EMBED_TABS
|
||||||
|
assert "env_config" in EMBED_TABS
|
||||||
assert "risk_policy" in EMBED_TABS
|
assert "risk_policy" in EMBED_TABS
|
||||||
assert "settings" 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("/risk_policy") == "risk_policy"
|
||||||
assert path_to_embed_tab("/settings") == "settings"
|
assert path_to_embed_tab("/settings") == "settings"
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user