系统设置增加「系统更新」Tab:中控本机更新与客户端远程更新,实例页入口移除。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -526,6 +526,39 @@ def api_open_url(iid: int):
|
|||||||
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]})
|
return jsonify({"ok": True, "url": url, "base_url": inst["base_url"]})
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/settings/update/hub/version", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def api_hub_self_version():
|
||||||
|
from lib.update_lib import read_git_version
|
||||||
|
|
||||||
|
return jsonify(read_git_version())
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/settings/update/hub", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
def api_hub_self_update():
|
||||||
|
"""系统设置:中控本机快速更新."""
|
||||||
|
from lib.update_lib import schedule_pull_and_restart
|
||||||
|
|
||||||
|
result = schedule_pull_and_restart(delay_seconds=1.5)
|
||||||
|
return jsonify(result), (200 if result.get("ok") else 400)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/settings/update/clients", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
def api_update_clients():
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"id": i["id"],
|
||||||
|
"name": i.get("name") or f"#{i['id']}",
|
||||||
|
"base_url": i.get("base_url") or "",
|
||||||
|
"enabled": bool(i.get("enabled")),
|
||||||
|
}
|
||||||
|
for i in list_instances(enabled_only=False)
|
||||||
|
]
|
||||||
|
return jsonify({"ok": True, "items": items})
|
||||||
|
|
||||||
|
|
||||||
@app.route("/api/instances/<int:iid>/version", methods=["GET"])
|
@app.route("/api/instances/<int:iid>/version", methods=["GET"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_instance_version(iid: int):
|
def api_instance_version(iid: int):
|
||||||
@@ -540,7 +573,7 @@ def api_instance_version(iid: int):
|
|||||||
@app.route("/api/instances/<int:iid>/update", methods=["POST"])
|
@app.route("/api/instances/<int:iid>/update", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
def api_instance_update(iid: int):
|
def api_instance_update(iid: int):
|
||||||
"""实例页远程快速更新:转发客户端 Bridge /api/hub/update."""
|
"""系统设置远程更新客户端:转发 Bridge /api/hub/update."""
|
||||||
inst = get_instance(iid)
|
inst = get_instance(iid)
|
||||||
if not inst:
|
if not inst:
|
||||||
return jsonify({"ok": False, "msg": "实例不存在"}), 404
|
return jsonify({"ok": False, "msg": "实例不存在"}), 404
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""中控本机快速更新:git pull + pm2 restart."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import shlex
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
_update_lock = threading.Lock()
|
||||||
|
_update_pending_until = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_root() -> Path:
|
||||||
|
env_root = (os.getenv("REPO") or "").strip()
|
||||||
|
if env_root:
|
||||||
|
return Path(env_root)
|
||||||
|
return _ROOT
|
||||||
|
|
||||||
|
|
||||||
|
def _run_git(args: list[str], *, cwd: Path) -> tuple[bool, str]:
|
||||||
|
try:
|
||||||
|
proc = subprocess.run(
|
||||||
|
["git", *args],
|
||||||
|
cwd=str(cwd),
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=15,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False, "未找到 git"
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
return False, "git 超时"
|
||||||
|
except Exception as e:
|
||||||
|
return False, str(e) or "git 失败"
|
||||||
|
out = (proc.stdout or "").strip() or (proc.stderr or "").strip()
|
||||||
|
return proc.returncode == 0, out
|
||||||
|
|
||||||
|
|
||||||
|
def read_git_version() -> dict[str, Any]:
|
||||||
|
root = _repo_root()
|
||||||
|
out: dict[str, Any] = {
|
||||||
|
"ok": True,
|
||||||
|
"repo": str(root),
|
||||||
|
"sha": "",
|
||||||
|
"short_sha": "",
|
||||||
|
"branch": "",
|
||||||
|
"dirty": False,
|
||||||
|
"msg": "",
|
||||||
|
"online": True,
|
||||||
|
}
|
||||||
|
if not (root / ".git").exists() and not (root / ".git").is_file():
|
||||||
|
out["ok"] = False
|
||||||
|
out["msg"] = "非 git 仓库"
|
||||||
|
return out
|
||||||
|
ok, sha = _run_git(["rev-parse", "HEAD"], cwd=root)
|
||||||
|
if not ok or not sha:
|
||||||
|
out["ok"] = False
|
||||||
|
out["msg"] = sha or "无法读取 HEAD"
|
||||||
|
return out
|
||||||
|
out["sha"] = sha
|
||||||
|
out["short_sha"] = sha[:7]
|
||||||
|
ok_b, branch = _run_git(["rev-parse", "--abbrev-ref", "HEAD"], cwd=root)
|
||||||
|
if ok_b and branch:
|
||||||
|
out["branch"] = branch
|
||||||
|
ok_d, dirty = _run_git(["status", "--porcelain"], cwd=root)
|
||||||
|
if ok_d:
|
||||||
|
out["dirty"] = bool(dirty.strip())
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_pull_and_restart(*, delay_seconds: float = 1.5) -> dict[str, Any]:
|
||||||
|
if not sys.platform.startswith("linux"):
|
||||||
|
return {"ok": False, "msg": "仅 Linux 服务器支持更新", "deferred": False}
|
||||||
|
root = _repo_root()
|
||||||
|
script = root / "deploy" / "pull_and_restart.sh"
|
||||||
|
if not script.is_file():
|
||||||
|
return {"ok": False, "msg": f"缺少更新脚本: {script}", "deferred": False}
|
||||||
|
|
||||||
|
global _update_pending_until
|
||||||
|
now = time.time()
|
||||||
|
with _update_lock:
|
||||||
|
if now < _update_pending_until:
|
||||||
|
return {"ok": False, "msg": "已有更新任务进行中,请稍后再试", "deferred": False}
|
||||||
|
_update_pending_until = now + 120.0
|
||||||
|
|
||||||
|
version = read_git_version()
|
||||||
|
try:
|
||||||
|
delay = max(0.5, float(delay_seconds))
|
||||||
|
cmd = (
|
||||||
|
f"sleep {delay} && "
|
||||||
|
f"cd {shlex.quote(str(root))} && "
|
||||||
|
f"exec bash {shlex.quote(str(script))}"
|
||||||
|
)
|
||||||
|
subprocess.Popen(
|
||||||
|
["bash", "-c", cmd],
|
||||||
|
stdout=subprocess.DEVNULL,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
start_new_session=True,
|
||||||
|
cwd=str(root),
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"msg": "更新已受理,即将 git pull 并重启",
|
||||||
|
"deferred": True,
|
||||||
|
"before_sha": version.get("sha") or "",
|
||||||
|
"before_short_sha": version.get("short_sha") or "",
|
||||||
|
"branch": version.get("branch") or "",
|
||||||
|
}
|
||||||
|
except FileNotFoundError:
|
||||||
|
with _update_lock:
|
||||||
|
_update_pending_until = 0.0
|
||||||
|
return {"ok": False, "msg": "未找到 bash", "deferred": False}
|
||||||
|
except Exception as e:
|
||||||
|
with _update_lock:
|
||||||
|
_update_pending_until = 0.0
|
||||||
|
return {"ok": False, "msg": str(e) or "触发更新失败", "deferred": False}
|
||||||
+14
-27
@@ -330,36 +330,21 @@ button, .btn {
|
|||||||
}
|
}
|
||||||
.check { display: flex !important; align-items: center; gap: 8px; }
|
.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 { margin: 10px 0; border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; }
|
||||||
.inst-row > summary {
|
.update-version {
|
||||||
display: flex;
|
font-size: 0.82rem;
|
||||||
flex-wrap: wrap;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px 12px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.inst-version {
|
|
||||||
font-size: 0.78rem;
|
|
||||||
font-variant-numeric: tabular-nums;
|
font-variant-numeric: tabular-nums;
|
||||||
margin-left: auto;
|
|
||||||
}
|
}
|
||||||
.inst-version.online { color: var(--ok); }
|
.update-version.online { color: var(--ok); }
|
||||||
.inst-version.offline { color: var(--bad); }
|
.update-version.offline { color: var(--bad); }
|
||||||
.inst-remote-actions {
|
.update-status {
|
||||||
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;
|
font-size: 0.8rem;
|
||||||
min-height: 1.2em;
|
min-height: 1.2em;
|
||||||
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
.inst-update-status.pending { color: var(--accent-2); }
|
.update-status.pending { color: var(--accent-2); }
|
||||||
.inst-update-status.ok { color: var(--ok); }
|
.update-status.ok { color: var(--ok); }
|
||||||
.inst-update-status.err { color: var(--bad); }
|
.update-status.err { color: var(--bad); }
|
||||||
|
.update-table td { vertical-align: middle; }
|
||||||
.overview-table-wrap {
|
.overview-table-wrap {
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
@@ -667,7 +652,8 @@ button, .btn {
|
|||||||
}
|
}
|
||||||
#hub-set-account:checked ~ .settings-tabs label[for="hub-set-account"],
|
#hub-set-account:checked ~ .settings-tabs label[for="hub-set-account"],
|
||||||
#hub-set-wechat:checked ~ .settings-tabs label[for="hub-set-wechat"],
|
#hub-set-wechat:checked ~ .settings-tabs label[for="hub-set-wechat"],
|
||||||
#hub-set-backup:checked ~ .settings-tabs label[for="hub-set-backup"] {
|
#hub-set-backup:checked ~ .settings-tabs label[for="hub-set-backup"],
|
||||||
|
#hub-set-update:checked ~ .settings-tabs label[for="hub-set-update"] {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: rgba(34, 211, 238, 0.12);
|
background: rgba(34, 211, 238, 0.12);
|
||||||
border-color: rgba(56, 189, 248, 0.35);
|
border-color: rgba(56, 189, 248, 0.35);
|
||||||
@@ -681,7 +667,8 @@ button, .btn {
|
|||||||
}
|
}
|
||||||
#hub-set-account:checked ~ .settings-panels .settings-panel--account,
|
#hub-set-account:checked ~ .settings-panels .settings-panel--account,
|
||||||
#hub-set-wechat:checked ~ .settings-panels .settings-panel--wechat,
|
#hub-set-wechat:checked ~ .settings-panels .settings-panel--wechat,
|
||||||
#hub-set-backup:checked ~ .settings-panels .settings-panel--backup {
|
#hub-set-backup:checked ~ .settings-panels .settings-panel--backup,
|
||||||
|
#hub-set-update:checked ~ .settings-panels .settings-panel--update {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
.settings-grid {
|
.settings-grid {
|
||||||
|
|||||||
@@ -1,155 +0,0 @@
|
|||||||
(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();
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
(function (global) {
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
setTimeout(resolve, ms);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchJson(url, opts) {
|
||||||
|
const r = await fetch(url, Object || {});
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = await r.json();
|
||||||
|
} catch (e) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
return { httpOk: r.ok, status: r.status, data: data };
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtVer(v) {
|
||||||
|
if (!v) return "—";
|
||||||
|
if (v.online === false) return v.msg || "离线";
|
||||||
|
const short = v.short_sha || (v.sha ? String(v.sha).slice(0, 7) : "");
|
||||||
|
const branch = v.branch ? v.branch + " · " : "";
|
||||||
|
if (!short) return v.msg || (v.ok ? "未知版本" : "不可用");
|
||||||
|
return branch + short + (v.dirty ? " (dirty)" : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(el, text, cls) {
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text || "";
|
||||||
|
el.className = "update-status" + (cls ? " " + cls : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitRecover(versionUrl, beforeSha, statusEl, label) {
|
||||||
|
let sawDown = false;
|
||||||
|
const maxTries = 45;
|
||||||
|
for (let i = 0; i < maxTries; i++) {
|
||||||
|
await sleep(2000);
|
||||||
|
const res = await fetchJson(versionUrl);
|
||||||
|
const v = res.data || {};
|
||||||
|
const online = res.httpOk && (v.ok !== false) && (v.online !== false);
|
||||||
|
if (!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, (label || "更新完成") + ": " + (beforeShort || "?") + " → " + (afterShort || "?"), "ok");
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
if (sawDown) {
|
||||||
|
setStatus(
|
||||||
|
statusEl,
|
||||||
|
afterSha && beforeSha && afterSha === beforeSha
|
||||||
|
? "已重启上线(已是最新 " + (afterShort || "") + ")"
|
||||||
|
: "已上线 " + (afterShort || ""),
|
||||||
|
"ok"
|
||||||
|
);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
setStatus(statusEl, "更新中… (" + (i + 1) + "/" + maxTries + ")", "pending");
|
||||||
|
}
|
||||||
|
setStatus(statusEl, "超时未确认上线,请稍后刷新版本或查服务器日志", "err");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshHubVersion() {
|
||||||
|
const verEl = document.getElementById("hub-update-version");
|
||||||
|
if (!verEl) return null;
|
||||||
|
verEl.textContent = "探测中…";
|
||||||
|
verEl.className = "update-version";
|
||||||
|
const res = await fetchJson("/api/settings/update/hub/version");
|
||||||
|
const v = res.data || {};
|
||||||
|
if (res.httpOk && v.ok) {
|
||||||
|
verEl.textContent = fmtVer(v);
|
||||||
|
verEl.className = "update-version online";
|
||||||
|
} else {
|
||||||
|
verEl.textContent = v.msg || "读取失败";
|
||||||
|
verEl.className = "update-version offline";
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runHubUpdate() {
|
||||||
|
const btn = document.getElementById("hub-update-btn");
|
||||||
|
const statusEl = document.getElementById("hub-update-status");
|
||||||
|
if (!btn) return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"确认更新中控?\n\n将执行 git pull + pm2 restart。\n页面会短暂不可用,完成后自动探测版本。"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
setStatus(statusEl, "读取当前版本…", "pending");
|
||||||
|
const before = await refreshHubVersion();
|
||||||
|
const beforeSha = (before && before.sha) || "";
|
||||||
|
setStatus(statusEl, "下发更新指令…", "pending");
|
||||||
|
const res = await fetchJson("/api/settings/update/hub", {
|
||||||
|
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 waitRecover(
|
||||||
|
"/api/settings/update/hub/version",
|
||||||
|
beforeSha || data.before_sha || "",
|
||||||
|
statusEl,
|
||||||
|
"中控更新完成"
|
||||||
|
);
|
||||||
|
await refreshHubVersion();
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshClientRow(row) {
|
||||||
|
const iid = row.getAttribute("data-id");
|
||||||
|
const verEl = row.querySelector("[data-client-version]");
|
||||||
|
if (!iid || !verEl) return null;
|
||||||
|
verEl.textContent = "探测中…";
|
||||||
|
verEl.className = "update-version";
|
||||||
|
const res = await fetchJson("/api/instances/" + iid + "/version");
|
||||||
|
const v = res.data || {};
|
||||||
|
if (v.online) {
|
||||||
|
verEl.textContent = fmtVer(Object.assign({ online: true }, v));
|
||||||
|
verEl.className = "update-version online";
|
||||||
|
} else {
|
||||||
|
verEl.textContent = v.msg || "离线/不可达";
|
||||||
|
verEl.className = "update-version offline";
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runClientUpdate(row) {
|
||||||
|
const iid = row.getAttribute("data-id");
|
||||||
|
const name = row.getAttribute("data-name") || ("#" + iid);
|
||||||
|
const btn = row.querySelector("[data-client-update]");
|
||||||
|
const statusEl = row.querySelector("[data-client-status]");
|
||||||
|
if (!iid || !btn) return;
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
"确认远程更新「" +
|
||||||
|
name +
|
||||||
|
"」?\n\n将执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。"
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.disabled = true;
|
||||||
|
setStatus(statusEl, "读取当前版本…", "pending");
|
||||||
|
const before = await refreshClientRow(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 waitRecover(
|
||||||
|
"/api/instances/" + iid + "/version",
|
||||||
|
beforeSha || data.before_sha || "",
|
||||||
|
statusEl,
|
||||||
|
"更新完成"
|
||||||
|
);
|
||||||
|
await refreshClientRow(row);
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadClients() {
|
||||||
|
const body = document.getElementById("client-update-body");
|
||||||
|
if (!body) return;
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
|
||||||
|
try {
|
||||||
|
const res = await fetchJson("/api/settings/update/clients");
|
||||||
|
const items = (res.data && res.data.items) || [];
|
||||||
|
if (!items.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="4" class="muted">暂无登记实例</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = items
|
||||||
|
.map(function (it) {
|
||||||
|
const id = it.id;
|
||||||
|
const name = it.name || "#" + id;
|
||||||
|
const enabled = !!it.enabled;
|
||||||
|
return (
|
||||||
|
'<tr class="client-update-row" data-id="' +
|
||||||
|
escapeHtml(String(id)) +
|
||||||
|
'" data-name="' +
|
||||||
|
escapeHtml(name) +
|
||||||
|
'">' +
|
||||||
|
"<td><strong>" +
|
||||||
|
escapeHtml(name) +
|
||||||
|
"</strong><div class=\"muted\" style=\"font-size:.75rem\">" +
|
||||||
|
escapeHtml(it.base_url || "") +
|
||||||
|
"</div></td>" +
|
||||||
|
'<td><span class="update-version muted" data-client-version>—</span></td>' +
|
||||||
|
"<td>" +
|
||||||
|
(enabled ? "启用" : '<span class="muted">停用</span>') +
|
||||||
|
"</td>" +
|
||||||
|
'<td style="white-space:nowrap">' +
|
||||||
|
'<button type="button" class="btn btn-sm" data-client-update' +
|
||||||
|
(enabled ? "" : " disabled title=\"实例未启用\"") +
|
||||||
|
">远程更新</button> " +
|
||||||
|
'<button type="button" class="btn btn-sm btn-secondary" data-client-refresh>刷新版本</button>' +
|
||||||
|
' <span class="update-status muted" data-client-status></span>' +
|
||||||
|
"</td>" +
|
||||||
|
"</tr>"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
body.querySelectorAll(".client-update-row").forEach(function (row) {
|
||||||
|
const upd = row.querySelector("[data-client-update]");
|
||||||
|
const ref = row.querySelector("[data-client-refresh]");
|
||||||
|
if (upd) {
|
||||||
|
upd.addEventListener("click", function () {
|
||||||
|
runClientUpdate(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (ref) {
|
||||||
|
ref.addEventListener("click", function () {
|
||||||
|
refreshClientRow(row);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
refreshClientRow(row);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
body.innerHTML =
|
||||||
|
'<tr><td colspan="4" class="muted">' + escapeHtml((e && e.message) || "加载失败") + "</td></tr>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
const hubBtn = document.getElementById("hub-update-btn");
|
||||||
|
const hubRefresh = document.getElementById("hub-update-refresh");
|
||||||
|
const clientsRefresh = document.getElementById("client-update-refresh");
|
||||||
|
if (hubBtn) hubBtn.addEventListener("click", runHubUpdate);
|
||||||
|
if (hubRefresh) hubRefresh.addEventListener("click", refreshHubVersion);
|
||||||
|
if (clientsRefresh) clientsRefresh.addEventListener("click", loadClients);
|
||||||
|
refreshHubVersion();
|
||||||
|
loadClients();
|
||||||
|
}
|
||||||
|
|
||||||
|
global.HubSettingsUpdate = { boot: boot };
|
||||||
|
})(window);
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>{% block title %}期权中控{% endblock %}</title>
|
<title>{% block title %}期权中控{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=10">
|
<link rel="stylesheet" href="{{ url_for('static', filename='hub.css') }}?v=11">
|
||||||
</head>
|
</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 %}">
|
<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') %}
|
{% if session.get('logged_in') %}
|
||||||
|
|||||||
@@ -23,20 +23,12 @@
|
|||||||
<p class="muted">暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.</p>
|
<p class="muted">暂无实例.先在 crypto_okx 配置 HUB_BRIDGE_TOKEN 并公网可访问.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% for inst in instances %}
|
{% for inst in instances %}
|
||||||
<details class="inst-row" data-id="{{ inst.id }}" data-name="{{ inst.name }}">
|
<details class="inst-row">
|
||||||
<summary>
|
<summary>
|
||||||
<strong>{{ inst.name }}</strong>
|
<strong>{{ inst.name }}</strong>
|
||||||
<span class="muted">{{ inst.base_url }}</span>
|
<span class="muted">{{ inst.base_url }}</span>
|
||||||
{% if inst.enabled %}启用{% else %}停用{% endif %}
|
{% if inst.enabled %}启用{% else %}停用{% endif %}
|
||||||
<span class="inst-version muted" data-inst-version>—</span>
|
|
||||||
</summary>
|
</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">
|
<form method="post" class="form-grid">
|
||||||
<input type="hidden" name="action" value="update">
|
<input type="hidden" name="action" value="update">
|
||||||
<input type="hidden" name="id" value="{{ inst.id }}">
|
<input type="hidden" name="id" value="{{ inst.id }}">
|
||||||
@@ -53,10 +45,11 @@
|
|||||||
<input type="hidden" name="id" value="{{ inst.id }}">
|
<input type="hidden" name="id" value="{{ inst.id }}">
|
||||||
<button type="submit" class="btn-danger">删除</button>
|
<button type="submit" class="btn-danger">删除</button>
|
||||||
</form>
|
</form>
|
||||||
|
<p>
|
||||||
|
<a class="btn" href="/desk?id={{ inst.id }}">内嵌打开</a>
|
||||||
|
<a class="btn btn-secondary" href="/settings?tab=update">系统更新</a>
|
||||||
|
</p>
|
||||||
</details>
|
</details>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</section>
|
</section>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
|
||||||
<script src="{{ url_for('static', filename='instances.js') }}?v=1"></script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|||||||
+47
-3
@@ -2,12 +2,12 @@
|
|||||||
{% block title %}系统设置 · 期权中控{% endblock %}
|
{% block title %}系统设置 · 期权中控{% endblock %}
|
||||||
{% block body %}
|
{% block body %}
|
||||||
{% set _tab = (request.args.get('tab') or 'account').strip() %}
|
{% set _tab = (request.args.get('tab') or 'account').strip() %}
|
||||||
{% if _tab not in ('account', 'wechat', 'backup') %}{% set _tab = 'account' %}{% endif %}
|
{% if _tab not in ('account', 'wechat', 'backup', 'update') %}{% set _tab = 'account' %}{% endif %}
|
||||||
<section class="page-head settings-head">
|
<section class="page-head settings-head">
|
||||||
<div>
|
<div>
|
||||||
<p class="settings-eyebrow">Hub Settings</p>
|
<p class="settings-eyebrow">Hub Settings</p>
|
||||||
<h1>系统设置</h1>
|
<h1>系统设置</h1>
|
||||||
<p class="muted settings-lead">账号、告警与备份在此维护;保存后立即生效。</p>
|
<p class="muted settings-lead">账号、告警、备份与系统更新在此维护;保存后立即生效。</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -15,11 +15,13 @@
|
|||||||
<input type="radio" name="hub-settings-tab" id="hub-set-account" class="settings-tab-radio"{% if _tab == 'account' %} checked{% endif %}>
|
<input type="radio" name="hub-settings-tab" id="hub-set-account" class="settings-tab-radio"{% if _tab == 'account' %} checked{% endif %}>
|
||||||
<input type="radio" name="hub-settings-tab" id="hub-set-wechat" class="settings-tab-radio"{% if _tab == 'wechat' %} checked{% endif %}>
|
<input type="radio" name="hub-settings-tab" id="hub-set-wechat" class="settings-tab-radio"{% if _tab == 'wechat' %} checked{% endif %}>
|
||||||
<input type="radio" name="hub-settings-tab" id="hub-set-backup" class="settings-tab-radio"{% if _tab == 'backup' %} checked{% endif %}>
|
<input type="radio" name="hub-settings-tab" id="hub-set-backup" class="settings-tab-radio"{% if _tab == 'backup' %} checked{% endif %}>
|
||||||
|
<input type="radio" name="hub-settings-tab" id="hub-set-update" class="settings-tab-radio"{% if _tab == 'update' %} checked{% endif %}>
|
||||||
|
|
||||||
<div class="settings-tabs" role="tablist" aria-label="系统设置分类">
|
<div class="settings-tabs" role="tablist" aria-label="系统设置分类">
|
||||||
<label for="hub-set-account" class="settings-tab-btn" role="tab">登录账号</label>
|
<label for="hub-set-account" class="settings-tab-btn" role="tab">登录账号</label>
|
||||||
<label for="hub-set-wechat" class="settings-tab-btn" role="tab">企业微信</label>
|
<label for="hub-set-wechat" class="settings-tab-btn" role="tab">企业微信</label>
|
||||||
<label for="hub-set-backup" class="settings-tab-btn" role="tab">备份恢复</label>
|
<label for="hub-set-backup" class="settings-tab-btn" role="tab">备份恢复</label>
|
||||||
|
<label for="hub-set-update" class="settings-tab-btn" role="tab">系统更新</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="settings-panels">
|
<div class="settings-panels">
|
||||||
@@ -138,10 +140,52 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="settings-panel settings-panel--update" role="tabpanel">
|
||||||
|
<div class="settings-card-head">
|
||||||
|
<span class="settings-badge">Update</span>
|
||||||
|
<h2>系统更新</h2>
|
||||||
|
<p class="muted">快速更新 = git pull + pm2 restart。中控本机更新;客户端经 Bridge 远程下发。有持仓时慎用。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-backup-body">
|
||||||
|
<div class="backup-block">
|
||||||
|
<div class="backup-block-head">
|
||||||
|
<h3>中控更新</h3>
|
||||||
|
<p class="muted field-hint">当前版本 <code id="hub-update-version" class="update-version">—</code></p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="button" class="settings-primary" id="hub-update-btn">更新中控</button>
|
||||||
|
<button type="button" class="btn-secondary" id="hub-update-refresh">刷新版本</button>
|
||||||
|
<span class="update-status muted" id="hub-update-status"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="backup-block">
|
||||||
|
<div class="backup-block-head">
|
||||||
|
<h3>客户端远程更新</h3>
|
||||||
|
<p class="muted field-hint">经 Bridge 探测版本并下发更新;客户端需已具备 <code>/api/hub/update</code></p>
|
||||||
|
<div class="settings-actions">
|
||||||
|
<button type="button" class="btn-secondary" id="client-update-refresh">刷新列表</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table class="backup-table update-table">
|
||||||
|
<thead><tr><th>实例</th><th>版本</th><th>状态</th><th>操作</th></tr></thead>
|
||||||
|
<tbody id="client-update-body"><tr><td colspan="4" class="muted">加载中…</td></tr></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=2"></script>
|
<script src="{{ url_for('static', filename='settings_backup.js') }}?v=2"></script>
|
||||||
<script>HubSettingsBackup.boot();</script>
|
<script src="{{ url_for('static', filename='settings_update.js') }}?v=1"></script>
|
||||||
|
<script>
|
||||||
|
HubSettingsBackup.boot();
|
||||||
|
HubSettingsUpdate.boot();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user