348 lines
13 KiB
JavaScript
348 lines
13 KiB
JavaScript
(function () {
|
||
const toast = document.getElementById("toast");
|
||
let settingsCache = null;
|
||
let monitorTimer = null;
|
||
|
||
function showToast(msg, isErr) {
|
||
toast.textContent = msg;
|
||
toast.style.borderColor = isErr ? "var(--red)" : "var(--border)";
|
||
toast.classList.add("show");
|
||
clearTimeout(showToast._t);
|
||
showToast._t = setTimeout(() => toast.classList.remove("show"), 7000);
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s)
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function fmt(n, d) {
|
||
if (n === null || n === undefined || Number.isNaN(Number(n))) return "—";
|
||
return Number(n).toLocaleString(undefined, { maximumFractionDigits: d });
|
||
}
|
||
|
||
function pnlCls(v) {
|
||
const n = Number(v);
|
||
if (!Number.isFinite(n) || n === 0) return "";
|
||
return n > 0 ? "pnl-pos" : "pnl-neg";
|
||
}
|
||
|
||
function currentPage() {
|
||
const p = window.location.pathname.replace(/\/$/, "") || "/monitor";
|
||
if (p.includes("settings")) return "settings";
|
||
return "monitor";
|
||
}
|
||
|
||
function setActiveNav() {
|
||
const page = currentPage();
|
||
document.querySelectorAll(".top-nav a").forEach((a) => {
|
||
a.classList.toggle("active", a.getAttribute("href").includes(page));
|
||
});
|
||
document.querySelectorAll(".page").forEach((el) => {
|
||
el.classList.toggle("hidden", !el.id.includes(page));
|
||
});
|
||
if (page === "monitor") startMonitorPoll();
|
||
else stopMonitorPoll();
|
||
if (page === "settings") loadSettingsUI();
|
||
}
|
||
|
||
function stopMonitorPoll() {
|
||
clearInterval(monitorTimer);
|
||
monitorTimer = null;
|
||
}
|
||
|
||
function startMonitorPoll() {
|
||
stopMonitorPoll();
|
||
loadMonitorBoard();
|
||
if (document.getElementById("auto-monitor").checked) {
|
||
monitorTimer = setInterval(loadMonitorBoard, 5000);
|
||
}
|
||
}
|
||
|
||
async function loadSettings() {
|
||
const r = await fetch("/api/settings");
|
||
settingsCache = await r.json();
|
||
return settingsCache;
|
||
}
|
||
|
||
function enabledAccounts() {
|
||
return (settingsCache?.exchanges || []).filter((x) => x.enabled);
|
||
}
|
||
|
||
async function loadMonitorBoard() {
|
||
const box = document.getElementById("monitor-grid");
|
||
try {
|
||
const r = await fetch("/api/monitor/board");
|
||
const data = await r.json();
|
||
document.getElementById("monitor-updated").textContent =
|
||
"更新于 " + (data.updated_at || "").replace("T", " ");
|
||
const parts = (data.rows || []).map(renderMonitorCard);
|
||
box.innerHTML = parts.join("") || '<div class="err">无已启用账户</div>';
|
||
box.querySelectorAll(".btn-close-ex").forEach((btn) => {
|
||
btn.onclick = () => closeOne(btn.dataset.id);
|
||
});
|
||
} catch (e) {
|
||
box.innerHTML = `<div class="err">${esc(e)}</div>`;
|
||
}
|
||
}
|
||
|
||
function renderMonitorCard(row) {
|
||
const ag = row.agent || {};
|
||
const pos = Array.isArray(ag.positions) ? ag.positions : [];
|
||
const hm = row.hub_monitor || {};
|
||
const flaskOk = row.flask_ok !== false && hm.ok !== false;
|
||
const keys = flaskOk ? hm.keys || [] : [];
|
||
const orders = flaskOk ? hm.orders || [] : [];
|
||
const trends = flaskOk ? hm.trends || [] : [];
|
||
const kmap = {};
|
||
(row.key_prices || []).forEach((k) => {
|
||
kmap[k.id] = k;
|
||
});
|
||
let inner = "";
|
||
const agOk = ag.ok !== false;
|
||
const agErr = ag.error || row.error || "";
|
||
if (!row.http_ok) {
|
||
inner = `<div class="err">${esc(row.error || "子代理不可用")}</div>`;
|
||
} else if (!agOk) {
|
||
inner = `<div class="err">${esc(agErr || "子代理返回失败")}</div>`;
|
||
inner += `<div class="empty-hint">请检查 PM2 子代理与 <code>${esc(row.agent_url || "")}/status</code></div>`;
|
||
} else {
|
||
inner = `<div class="stat-row">
|
||
<div class="stat-box"><div class="stat-label">余额</div><div class="stat-value">${fmt(ag.balance_usdt, 2)} <small style="font-size:12px;color:var(--muted)">U</small></div></div>
|
||
<div class="stat-box"><div class="stat-label">浮盈合计</div><div class="stat-value ${pnlCls(ag.total_unrealized_pnl)}">${fmt(ag.total_unrealized_pnl, 4)}</div></div>
|
||
</div>`;
|
||
inner += `<div class="section-title">交易所持仓</div>`;
|
||
if (pos.length) {
|
||
const posRows = pos
|
||
.map(
|
||
(x) =>
|
||
`<tr><td>${esc(x.symbol)}</td><td>${esc(x.side)}</td><td>${fmt(x.contracts, 4)}</td><td class="${pnlCls(x.unrealized_pnl)}">${fmt(x.unrealized_pnl, 4)}</td></tr>`
|
||
)
|
||
.join("");
|
||
inner += `<table class="data-table"><thead><tr><th>合约</th><th>方向</th><th>张数</th><th>浮盈</th></tr></thead><tbody>${posRows}</tbody></table>`;
|
||
} else {
|
||
inner += `<div class="empty-hint">无持仓</div>`;
|
||
}
|
||
if (orders.length) {
|
||
inner += `<div class="section-title">机器人单 · ${orders.length}</div>`;
|
||
orders.forEach((o) => {
|
||
inner += `<div class="list-line">${esc(o.symbol)} · ${esc(o.direction)} · 触发 ${o.trigger_price}</div>`;
|
||
});
|
||
}
|
||
if ((row.capabilities || []).includes("key")) {
|
||
inner += `<div class="section-title">关键位</div>`;
|
||
if (!flaskOk) {
|
||
const fe = row.flask_error || hm.msg || hm.error || "";
|
||
const short =
|
||
fe ||
|
||
(hm.status === 404
|
||
? "HTTP 404:请重启各 crypto_* Flask"
|
||
: "策略 Flask 未连通");
|
||
inner += `<div class="err">${esc(short)}</div>`;
|
||
} else if (!keys.length) {
|
||
inner += `<div class="empty-hint">当前无记录</div>`;
|
||
} else {
|
||
keys.slice(0, 8).forEach((k) => {
|
||
const kp = kmap[k.id] || kmap[String(k.id)] || {};
|
||
const mt = k.monitor_type || k.type || "";
|
||
let line = `${esc(k.symbol)} · ${esc(mt)} · ${k.upper} / ${k.lower}`;
|
||
if (kp.price_display != null || kp.price != null) {
|
||
line += ` · ${esc(kp.price_display != null ? kp.price_display : kp.price)}`;
|
||
}
|
||
line += ` · ${esc(kp.gate_summary || "-")}`;
|
||
inner += `<div class="list-line">${line}</div>`;
|
||
});
|
||
}
|
||
}
|
||
if (trends.length) {
|
||
inner += `<div class="section-title">趋势计划 · ${trends.length}</div>`;
|
||
trends.forEach((t) => {
|
||
inner += `<div class="list-line">#${t.id} ${esc(t.symbol)} ${t.direction} · SL ${t.stop_loss} · TP ${t.take_profit}</div>`;
|
||
});
|
||
}
|
||
}
|
||
const review = row.review_url
|
||
? `<a class="btn-link" href="${esc(row.review_url)}" target="_blank" rel="noopener">复盘</a>`
|
||
: "";
|
||
const flaskOpen = row.flask_url_browser || row.flask_url;
|
||
const openFlask = flaskOpen
|
||
? `<a class="btn-link" href="${esc(flaskOpen)}" target="_blank" rel="noopener">实例</a>`
|
||
: "";
|
||
return `<div class="card">
|
||
<div class="card-head">
|
||
<div>
|
||
<div class="card-title">${esc(row.name)}</div>
|
||
<div class="card-sub">${esc(flaskOpen || "")}</div>
|
||
</div>
|
||
<div class="card-actions">
|
||
${openFlask}
|
||
${review}
|
||
<button type="button" class="danger btn-close-ex" data-id="${esc(row.id)}">全平</button>
|
||
</div>
|
||
</div>
|
||
<div class="card-body">${inner}</div>
|
||
</div>`;
|
||
}
|
||
|
||
async function closeOne(id) {
|
||
if (!confirm("确认对该账户市价全平?")) return;
|
||
try {
|
||
const r = await fetch("/api/close/" + encodeURIComponent(id), { method: "POST" });
|
||
const j = await r.json();
|
||
showToast(JSON.stringify(j, null, 2), !r.ok);
|
||
loadMonitorBoard();
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
async function closeAll() {
|
||
const n = enabledAccounts().length;
|
||
if (!confirm(`对 ${n} 个已启用账户执行紧急全平?`)) return;
|
||
try {
|
||
const r = await fetch("/api/close-all", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ exclude_ids: [] }),
|
||
});
|
||
const j = await r.json();
|
||
showToast(JSON.stringify(j, null, 2), !r.ok);
|
||
loadMonitorBoard();
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
async function loadSettingsMetaLine() {
|
||
try {
|
||
const r = await fetch("/api/settings/meta");
|
||
const m = await r.json();
|
||
const el = document.getElementById("settings-meta-line");
|
||
if (!el) return;
|
||
const parts = [];
|
||
if (m.hub_bridge_token_set) parts.push("中控已配置 HUB_BRIDGE_TOKEN");
|
||
else parts.push("中控未设 HUB_BRIDGE_TOKEN(实例需 APP_AUTH_DISABLED 或同令牌)");
|
||
if (m.public_origin) parts.push("浏览器外链基址: " + m.public_origin);
|
||
else parts.push("未设 HUB_PUBLIC_ORIGIN(复盘链接仅本机可开)");
|
||
if ((m.env_disabled_ids || []).length)
|
||
parts.push("环境强制关闭 id: " + m.env_disabled_ids.join(", "));
|
||
el.textContent = parts.join(" · ");
|
||
} catch (_) {}
|
||
}
|
||
|
||
function loadSettingsUI() {
|
||
loadSettingsMetaLine();
|
||
loadSettings().then((data) => {
|
||
const list = document.getElementById("settings-list");
|
||
document.getElementById("settings-list").innerHTML = (data.exchanges || [])
|
||
.map((ex, idx) => renderSettingsCard(ex, idx))
|
||
.join("");
|
||
list.querySelectorAll(".btn-del-ex").forEach((btn) => {
|
||
btn.onclick = () => {
|
||
const i = Number(btn.dataset.idx);
|
||
data.exchanges.splice(i, 1);
|
||
settingsCache = data;
|
||
loadSettingsUI();
|
||
};
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderSettingsCard(ex, idx) {
|
||
const caps = ex.capabilities || [];
|
||
const envOff = ex.env_disabled
|
||
? '<span class="badge">环境变量强制关</span>'
|
||
: "";
|
||
return `<div class="settings-card" data-idx="${idx}" data-key="${esc(ex.key || ex.id || "")}">
|
||
<div class="settings-card-head">
|
||
<label class="chk-label"><input type="checkbox" class="ex-enabled" ${ex.enabled ? "checked" : ""} ${ex.env_disabled ? "disabled" : ""}/> 启用</label>
|
||
${envOff}
|
||
<input class="ex-name" value="${esc(ex.name || "")}" placeholder="显示名称" />
|
||
</div>
|
||
<div class="settings-grid">
|
||
<div class="field"><label>Flask URL</label><input class="ex-flask" value="${esc(ex.flask_url || "")}" /></div>
|
||
<div class="field"><label>Agent URL</label><input class="ex-agent" value="${esc(ex.agent_url || "")}" /></div>
|
||
<div class="field field-wide"><label>复盘链接(可空)</label><input class="ex-review" value="${esc(ex.review_url || "")}" placeholder="留空则自动生成 /records" /></div>
|
||
</div>
|
||
<div class="cap-chips">
|
||
<label><input type="checkbox" class="cap-key" ${caps.includes("key") ? "checked" : ""}/> 监控关键位</label>
|
||
<label><input type="checkbox" class="cap-trend" ${caps.includes("trend") ? "checked" : ""}/> 监控趋势计划</label>
|
||
</div>
|
||
<div class="settings-card-foot">
|
||
<div class="field"><label>id</label><input class="ex-id" value="${esc(ex.id || "")}" /></div>
|
||
<button type="button" class="danger btn-del-ex" data-idx="${idx}">删除账户</button>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function collectSettingsFromUI() {
|
||
const rows = [...document.querySelectorAll("#settings-list .settings-card")];
|
||
return {
|
||
version: 1,
|
||
exchanges: rows.map((card) => {
|
||
const caps = [];
|
||
if (card.querySelector(".cap-key").checked) caps.push("key");
|
||
if (card.querySelector(".cap-trend").checked) caps.push("trend");
|
||
const id = card.querySelector(".ex-id").value.trim();
|
||
const stableKey = (card.dataset.key || id).trim();
|
||
return {
|
||
id: id,
|
||
key: stableKey,
|
||
name: card.querySelector(".ex-name").value.trim(),
|
||
flask_url: card.querySelector(".ex-flask").value.trim(),
|
||
agent_url: card.querySelector(".ex-agent").value.trim(),
|
||
review_url: card.querySelector(".ex-review").value.trim(),
|
||
enabled: card.querySelector(".ex-enabled").checked,
|
||
capabilities: caps,
|
||
};
|
||
}),
|
||
};
|
||
}
|
||
|
||
async function saveSettings() {
|
||
const body = collectSettingsFromUI();
|
||
try {
|
||
const r = await fetch("/api/settings", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
const j = await r.json();
|
||
if (j.ok) {
|
||
showToast("设置已保存(已写入 hub_settings.json)");
|
||
await loadSettingsUI();
|
||
} else showToast("保存失败", true);
|
||
} catch (e) {
|
||
showToast(String(e), true);
|
||
}
|
||
}
|
||
|
||
document.getElementById("btn-monitor-refresh").onclick = loadMonitorBoard;
|
||
document.getElementById("auto-monitor").onchange = startMonitorPoll;
|
||
document.getElementById("btn-close-all").onclick = closeAll;
|
||
document.getElementById("btn-settings-save").onclick = saveSettings;
|
||
document.getElementById("btn-settings-reload").onclick = loadSettingsUI;
|
||
document.getElementById("btn-settings-add").onclick = () => {
|
||
const data = settingsCache || { exchanges: [] };
|
||
const nid = String(Date.now() % 100000);
|
||
data.exchanges.push({
|
||
id: nid,
|
||
key: "custom_" + nid,
|
||
name: "新交易所",
|
||
flask_url: "http://127.0.0.1:5000",
|
||
agent_url: "http://127.0.0.1:15200",
|
||
review_url: "",
|
||
enabled: false,
|
||
capabilities: ["key"],
|
||
});
|
||
settingsCache = data;
|
||
loadSettingsUI();
|
||
};
|
||
|
||
setActiveNav();
|
||
window.addEventListener("popstate", setActiveNav);
|
||
})();
|