diff --git a/app.py b/app.py index 444249b..9f34c0e 100644 --- a/app.py +++ b/app.py @@ -42,7 +42,9 @@ from lib.bridge_client_lib import ( dispatch_cond_order_to_ids, fetch_backup_file, fetch_backup_list, + fetch_client_version, fetch_cond_orders, + post_client_update, post_close_full, post_cond_cancel, post_cond_exit, @@ -524,6 +526,37 @@ def api_open_url(iid: int): return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]}) +@app.route("/api/instances//version", methods=["GET"]) +@login_required +def api_instance_version(iid: int): + inst = get_instance(iid) + if not inst: + return jsonify({"ok": False, "msg": "实例不存在", "online": False}), 404 + result = fetch_client_version(inst) + code = 200 if result.get("online") else 502 + return jsonify(result), code + + +@app.route("/api/instances//update", methods=["POST"]) +@login_required +def api_instance_update(iid: int): + """实例页远程快速更新:转发客户端 Bridge /api/hub/update.""" + inst = get_instance(iid) + if not inst: + return jsonify({"ok": False, "msg": "实例不存在"}), 404 + if not inst.get("enabled"): + return jsonify({"ok": False, "msg": "实例未启用"}), 400 + result = post_client_update(inst) + status = 200 + if not result.get("ok"): + msg = str(result.get("msg") or "") + if msg.startswith("HTTP ") or "超时" in msg or "下发失败" in msg or "缺少 URL" in msg: + status = 502 + else: + status = 400 + return jsonify(result), status + + @app.route("/api/instances//options/close", methods=["POST"]) @login_required def api_instance_options_close(iid: int): diff --git a/lib/bridge_client_lib.py b/lib/bridge_client_lib.py index bbfcf22..14c12e2 100644 --- a/lib/bridge_client_lib.py +++ b/lib/bridge_client_lib.py @@ -269,6 +269,95 @@ def pull_all_stats(window: str = "7d") -> list[dict[str, Any]]: return results +def fetch_client_version(inst: dict[str, Any]) -> dict[str, Any]: + """探测客户端 git 版本 / 在线.""" + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + out: dict[str, Any] = { + "ok": False, + "online": False, + "sha": "", + "short_sha": "", + "branch": "", + "dirty": False, + "msg": "", + "instance_id": inst.get("id"), + "instance_name": inst.get("name"), + } + if not base or not token: + out["msg"] = "缺少 URL 或 Token" + return out + url = f"{base}/api/hub/version" + try: + r = requests.get( + url, + headers={"X-Hub-Token": token, "Accept": "application/json"}, + timeout=_timeout(), + ) + data = r.json() if r.content else {} + if not isinstance(data, dict): + out["msg"] = f"HTTP {r.status_code}" + return out + for key in ("sha", "short_sha", "branch", "dirty", "msg", "repo"): + if key in data: + out[key] = data.get(key) + out["ok"] = bool(data.get("ok")) and r.status_code == 200 + out["online"] = out["ok"] + if not out["ok"]: + out["msg"] = data.get("msg") or f"HTTP {r.status_code}" + return out + except requests.Timeout: + out["msg"] = "超时" + return out + except Exception as e: + out["msg"] = str(e) or "探测失败" + return out + + +def post_client_update(inst: dict[str, Any]) -> dict[str, Any]: + """触发客户端快速更新(git pull + pm2 restart).""" + base = (inst.get("base_url") or "").rstrip("/") + token = (inst.get("bridge_token") or "").strip() + if not base or not token: + return { + "ok": False, + "msg": "缺少 URL 或 Token", + "instance_id": inst.get("id"), + "instance_name": inst.get("name"), + } + url = f"{base}/api/hub/update" + try: + r = requests.post( + url, + headers=_headers(token), + json={}, + timeout=_timeout(), + ) + data = r.json() if r.content else {} + if not isinstance(data, dict): + data = {"ok": False, "msg": f"HTTP {r.status_code}"} + data["instance_id"] = inst.get("id") + data["instance_name"] = inst.get("name") + if r.status_code >= 400 and "ok" not in data: + data["ok"] = False + data.setdefault("msg", f"HTTP {r.status_code}") + return data + except requests.Timeout: + return { + "ok": False, + "msg": "客户端超时(更新可能仍在进行,请刷新版本确认)", + "instance_id": inst.get("id"), + "instance_name": inst.get("name"), + } + except Exception as e: + return { + "ok": False, + "msg": str(e) or "下发失败", + "instance_id": inst.get("id"), + "instance_name": inst.get("name"), + } + + def fetch_backup_list(inst: dict[str, Any]) -> dict[str, Any]: base = (inst.get("base_url") or "").rstrip("/") token = (inst.get("bridge_token") or "").strip() diff --git a/static/hub.css b/static/hub.css index fef253b..1c53826 100644 --- a/static/hub.css +++ b/static/hub.css @@ -330,6 +330,36 @@ button, .btn { } .check { display: flex !important; align-items: center; gap: 8px; } .inst-row { margin: 10px 0; border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; } +.inst-row > summary { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; + cursor: pointer; +} +.inst-version { + font-size: 0.78rem; + font-variant-numeric: tabular-nums; + margin-left: auto; +} +.inst-version.online { color: var(--ok); } +.inst-version.offline { color: var(--bad); } +.inst-remote-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 10px 0 4px; +} +.inst-remote-actions .btn { margin-top: 0; } +.inst-update-hint { margin: 0 0 12px; font-size: 0.78rem; } +.inst-update-status { + font-size: 0.8rem; + min-height: 1.2em; +} +.inst-update-status.pending { color: var(--accent-2); } +.inst-update-status.ok { color: var(--ok); } +.inst-update-status.err { color: var(--bad); } .overview-table-wrap { overflow-x: auto; margin-top: 12px; diff --git a/static/instances.js b/static/instances.js new file mode 100644 index 0000000..f57a4b3 --- /dev/null +++ b/static/instances.js @@ -0,0 +1,155 @@ +(function () { + function sleep(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); + } + + async function fetchJson(url, opts) { + const r = await fetch(url, opts || {}); + let data = {}; + try { + data = await r.json(); + } catch (e) { + data = {}; + } + return { httpOk: r.ok, status: r.status, data: data }; + } + + function setStatus(el, text, cls) { + if (!el) return; + el.textContent = text || ""; + el.className = "inst-update-status" + (cls ? " " + cls : ""); + } + + function fmtVer(v) { + if (!v || !v.online) return "离线"; + const short = v.short_sha || (v.sha ? String(v.sha).slice(0, 7) : ""); + const branch = v.branch ? v.branch + " · " : ""; + if (!short) return v.msg || "未知版本"; + return branch + short + (v.dirty ? " (dirty)" : ""); + } + + async function refreshVersion(row) { + const iid = row.getAttribute("data-id"); + const verEl = row.querySelector("[data-inst-version]"); + if (!iid || !verEl) return null; + verEl.textContent = "探测中…"; + const res = await fetchJson("/api/instances/" + iid + "/version"); + const v = res.data || {}; + if (v.online) { + verEl.textContent = fmtVer(v); + verEl.className = "inst-version online"; + } else { + verEl.textContent = (v.msg || "离线/不可达"); + verEl.className = "inst-version offline"; + } + return v; + } + + async function waitForUpdate(row, beforeSha) { + const statusEl = row.querySelector("[data-inst-update-status]"); + const iid = row.getAttribute("data-id"); + let sawDown = false; + const maxTries = 45; + for (let i = 0; i < maxTries; i++) { + await sleep(2000); + const res = await fetchJson("/api/instances/" + iid + "/version"); + const v = res.data || {}; + if (!v.online) { + sawDown = true; + setStatus(statusEl, "已受理 → 等待上线… (" + (i + 1) + "/" + maxTries + ")", "pending"); + continue; + } + const afterSha = v.sha || ""; + const afterShort = v.short_sha || (afterSha ? afterSha.slice(0, 7) : ""); + const beforeShort = beforeSha ? String(beforeSha).slice(0, 7) : ""; + if (afterSha && beforeSha && afterSha !== beforeSha) { + setStatus( + statusEl, + "更新完成: " + (beforeShort || "?") + " → " + (afterShort || "?"), + "ok" + ); + await refreshVersion(row); + return true; + } + if (sawDown) { + setStatus( + statusEl, + afterSha && beforeSha && afterSha === beforeSha + ? "已重启上线(已是最新 " + (afterShort || "") + ")" + : "已上线 " + (afterShort || ""), + "ok" + ); + await refreshVersion(row); + return true; + } + setStatus(statusEl, "更新中… (" + (i + 1) + "/" + maxTries + ")", "pending"); + } + setStatus(statusEl, "超时未确认上线,请稍后刷新版本或查客户端日志", "err"); + await refreshVersion(row); + return false; + } + + async function runUpdate(row) { + const iid = row.getAttribute("data-id"); + const name = row.getAttribute("data-name") || ("#" + iid); + const btn = row.querySelector("[data-inst-update]"); + const statusEl = row.querySelector("[data-inst-update-status]"); + if (!iid || !btn) return; + const ok = window.confirm( + "确认远程更新「" + + name + + "」?\n\n将执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。" + ); + if (!ok) return; + + btn.disabled = true; + setStatus(statusEl, "读取当前版本…", "pending"); + const before = await refreshVersion(row); + const beforeSha = (before && before.sha) || ""; + + setStatus(statusEl, "下发更新指令…", "pending"); + const res = await fetchJson("/api/instances/" + iid + "/update", { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: "{}", + }); + const data = res.data || {}; + if (!data.ok) { + setStatus(statusEl, data.msg || "更新失败", "err"); + btn.disabled = false; + return; + } + setStatus(statusEl, "已受理 → 等待上线…", "pending"); + try { + await waitForUpdate(row, beforeSha || data.before_sha || ""); + } finally { + btn.disabled = false; + } + } + + function bind() { + document.querySelectorAll(".inst-row[data-id]").forEach(function (row) { + const updateBtn = row.querySelector("[data-inst-update]"); + const refreshBtn = row.querySelector("[data-inst-refresh-ver]"); + if (updateBtn) { + updateBtn.addEventListener("click", function () { + runUpdate(row); + }); + } + if (refreshBtn) { + refreshBtn.addEventListener("click", function () { + refreshVersion(row); + }); + } + refreshVersion(row); + }); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bind); + } else { + bind(); + } +})(); diff --git a/templates/base.html b/templates/base.html index 893d554..ff37226 100644 --- a/templates/base.html +++ b/templates/base.html @@ -4,7 +4,7 @@ {% block title %}期权中控{% endblock %} - + {% if session.get('logged_in') %} diff --git a/templates/instances.html b/templates/instances.html index 6c5e89b..23955ad 100644 --- a/templates/instances.html +++ b/templates/instances.html @@ -1,54 +1,62 @@ -{% extends "base.html" %} -{% block title %}实例 · 期权中控{% endblock %} -{% block body %} -

客户端实例

- -
-

添加实例

-
- - - - - - - - -
-
- -
-

已登记 ({{ instances|length }})

- {% if not instances %} -

暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.

- {% endif %} - {% for inst in instances %} -
- - {{ inst.name }} - {{ inst.base_url }} - {% if inst.enabled %}启用{% else %}停用{% endif %} - -
- - - - - - - - - -
-
- - - -
-

- 内嵌打开 -

-
- {% endfor %} -
-{% endblock %} +{% extends "base.html" %} +{% block title %}实例 · 期权中控{% endblock %} +{% block body %} +

客户端实例

+ +
+

添加实例

+
+ + + + + + + + +
+
+ +
+

已登记 ({{ instances|length }})

+ {% if not instances %} +

暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.

+ {% endif %} + {% for inst in instances %} +
+ + {{ inst.name }} + {{ inst.base_url }} + {% if inst.enabled %}启用{% else %}停用{% endif %} + — + +
+ + + 内嵌打开 + +
+

远程更新 = 客户端 git pull + pm2 restart;有持仓时慎用。

+
+ + + + + + + + + +
+
+ + + +
+
+ {% endfor %} +
+{% endblock %} +{% block scripts %} + +{% endblock %}