实例页增加远程更新客户端:下发 git pull + pm2 restart,并轮询版本确认上线。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user