实例页增加远程更新客户端:下发 git pull + pm2 restart,并轮询版本确认上线。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-04 07:11:15 +08:00
parent f7ffd08274
commit 8e2576661a
6 changed files with 370 additions and 55 deletions
+33
View File
@@ -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/<int:iid>/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/<int:iid>/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/<int:iid>/options/close", methods=["POST"])
@login_required
def api_instance_options_close(iid: int):
+89
View File
@@ -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()
+30
View File
@@ -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;
+155
View File
@@ -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();
}
})();
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}期权中控{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=7">
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=8">
</head>
<body class="{% if request.path.startswith('/login') %}page-login{% elif request.path.startswith('/desk') %}page-desk{% elif request.path.startswith('/stats') %}page-stats{% elif request.path == '/' %}page-dashboard{% endif %}">
{% if session.get('logged_in') %}
+62 -54
View File
@@ -1,54 +1,62 @@
{% extends "base.html" %}
{% block title %}实例 · 期权中控{% endblock %}
{% block body %}
<section class="page-head"><h1>客户端实例</h1></section>
<section class="panel">
<h2>添加实例</h2>
<form method="post" class="form-grid">
<input type="hidden" name="action" value="create">
<label>名称 <input name="name" required placeholder="ETH-主策略"></label>
<label>公网 URL <input name="base_url" required placeholder="https://okx-eth.example.com"></label>
<label>Bridge Token <input name="bridge_token" required placeholder="与客户端 HUB_BRIDGE_TOKEN 一致"></label>
<label>Embed 路径 <input name="embed_path" value="/embed"></label>
<label>标签 <input name="tags" placeholder="eth,sim"></label>
<label class="check"><input type="checkbox" name="enabled" value="1" checked> 启用拉取</label>
<button type="submit">添加</button>
</form>
</section>
<section class="panel">
<h2>已登记 ({{ instances|length }})</h2>
{% if not instances %}
<p class="muted">暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.</p>
{% endif %}
{% for inst in instances %}
<details class="inst-row">
<summary>
<strong>{{ inst.name }}</strong>
<span class="muted">{{ inst.base_url }}</span>
{% if inst.enabled %}启用{% else %}停用{% endif %}
</summary>
<form method="post" class="form-grid">
<input type="hidden" name="action" value="update">
<input type="hidden" name="id" value="{{ inst.id }}">
<label>名称 <input name="name" value="{{ inst.name }}" required></label>
<label>公网 URL <input name="base_url" value="{{ inst.base_url }}" required></label>
<label>Bridge Token <input name="bridge_token" value="{{ inst.bridge_token }}" required></label>
<label>Embed 路径 <input name="embed_path" value="{{ inst.embed_path }}"></label>
<label>标签 <input name="tags" value="{{ inst.tags or '' }}"></label>
<label class="check"><input type="checkbox" name="enabled" value="1" {% if inst.enabled %}checked{% endif %}> 启用</label>
<button type="submit">保存</button>
</form>
<form method="post" class="inline" onsubmit="return confirm('确认删除?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{ inst.id }}">
<button type="submit" class="btn-danger">删除</button>
</form>
<p>
<a class="btn" href="/desk?id={{ inst.id }}">内嵌打开</a>
</p>
</details>
{% endfor %}
</section>
{% endblock %}
{% extends "base.html" %}
{% block title %}实例 · 期权中控{% endblock %}
{% block body %}
<section class="page-head"><h1>客户端实例</h1></section>
<section class="panel">
<h2>添加实例</h2>
<form method="post" class="form-grid">
<input type="hidden" name="action" value="create">
<label>名称 <input name="name" required placeholder="ETH-主策略"></label>
<label>公网 URL <input name="base_url" required placeholder="https://okx-eth.example.com"></label>
<label>Bridge Token <input name="bridge_token" required placeholder="与客户端 HUB_BRIDGE_TOKEN 一致"></label>
<label>Embed 路径 <input name="embed_path" value="/embed"></label>
<label>标签 <input name="tags" placeholder="eth,sim"></label>
<label class="check"><input type="checkbox" name="enabled" value="1" checked> 启用拉取</label>
<button type="submit">添加</button>
</form>
</section>
<section class="panel">
<h2>已登记 ({{ instances|length }})</h2>
{% if not instances %}
<p class="muted">暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.</p>
{% endif %}
{% for inst in instances %}
<details class="inst-row" data-id="{{ inst.id }}" data-name="{{ inst.name }}">
<summary>
<strong>{{ inst.name }}</strong>
<span class="muted">{{ inst.base_url }}</span>
{% if inst.enabled %}启用{% else %}停用{% endif %}
<span class="inst-version muted" data-inst-version>—</span>
</summary>
<div class="inst-remote-actions">
<button type="button" class="btn" data-inst-update {% if not inst.enabled %}disabled title="实例未启用"{% endif %}>远程更新</button>
<button type="button" class="btn btn-secondary btn-sm" data-inst-refresh-ver>刷新版本</button>
<a class="btn" href="/desk?id={{ inst.id }}">内嵌打开</a>
<span class="inst-update-status muted" data-inst-update-status></span>
</div>
<p class="muted inst-update-hint">远程更新 = 客户端 git pull + pm2 restart;有持仓时慎用。</p>
<form method="post" class="form-grid">
<input type="hidden" name="action" value="update">
<input type="hidden" name="id" value="{{ inst.id }}">
<label>名称 <input name="name" value="{{ inst.name }}" required></label>
<label>公网 URL <input name="base_url" value="{{ inst.base_url }}" required></label>
<label>Bridge Token <input name="bridge_token" value="{{ inst.bridge_token }}" required></label>
<label>Embed 路径 <input name="embed_path" value="{{ inst.embed_path }}"></label>
<label>标签 <input name="tags" value="{{ inst.tags or '' }}"></label>
<label class="check"><input type="checkbox" name="enabled" value="1" {% if inst.enabled %}checked{% endif %}> 启用</label>
<button type="submit">保存</button>
</form>
<form method="post" class="inline" onsubmit="return confirm('确认删除?')">
<input type="hidden" name="action" value="delete">
<input type="hidden" name="id" value="{{ inst.id }}">
<button type="submit" class="btn-danger">删除</button>
</form>
</details>
{% endfor %}
</section>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='instances.js') }}?v=1"></script>
{% endblock %}