From bce446178d60a9c9221ce72f614e8e18c76c563d Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 4 Oct 2026 07:24:36 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E8=AE=BE=E7=BD=AE=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E3=80=8C=E7=B3=BB=E7=BB=9F=E6=9B=B4=E6=96=B0=E3=80=8D?= =?UTF-8?q?Tab=EF=BC=9A=E4=B8=AD=E6=8E=A7=E6=9C=AC=E6=9C=BA=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E4=B8=8E=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=EF=BC=8C=E5=AE=9E=E4=BE=8B=E9=A1=B5=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E7=A7=BB=E9=99=A4=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- app.py | 35 ++++- lib/update_lib.py | 121 +++++++++++++++++ static/hub.css | 41 ++---- static/instances.js | 155 --------------------- static/settings_update.js | 275 ++++++++++++++++++++++++++++++++++++++ templates/base.html | 2 +- templates/instances.html | 17 +-- templates/settings.html | 50 ++++++- 8 files changed, 497 insertions(+), 199 deletions(-) create mode 100644 lib/update_lib.py delete mode 100644 static/instances.js create mode 100644 static/settings_update.js diff --git a/app.py b/app.py index 9f34c0e..1512080 100644 --- a/app.py +++ b/app.py @@ -526,6 +526,39 @@ def api_open_url(iid: int): 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//version", methods=["GET"]) @login_required def api_instance_version(iid: int): @@ -540,7 +573,7 @@ def api_instance_version(iid: int): @app.route("/api/instances//update", methods=["POST"]) @login_required def api_instance_update(iid: int): - """实例页远程快速更新:转发客户端 Bridge /api/hub/update.""" + """系统设置远程更新客户端:转发 Bridge /api/hub/update.""" inst = get_instance(iid) if not inst: return jsonify({"ok": False, "msg": "实例不存在"}), 404 diff --git a/lib/update_lib.py b/lib/update_lib.py new file mode 100644 index 0000000..88a0b39 --- /dev/null +++ b/lib/update_lib.py @@ -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} diff --git a/static/hub.css b/static/hub.css index 73aba50..123d107 100644 --- a/static/hub.css +++ b/static/hub.css @@ -330,36 +330,21 @@ 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; +.update-version { + font-size: 0.82rem; 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 { +.update-version.online { color: var(--ok); } +.update-version.offline { color: var(--bad); } +.update-status { font-size: 0.8rem; min-height: 1.2em; + margin-left: 6px; } -.inst-update-status.pending { color: var(--accent-2); } -.inst-update-status.ok { color: var(--ok); } -.inst-update-status.err { color: var(--bad); } +.update-status.pending { color: var(--accent-2); } +.update-status.ok { color: var(--ok); } +.update-status.err { color: var(--bad); } +.update-table td { vertical-align: middle; } .overview-table-wrap { overflow-x: auto; margin-top: 12px; @@ -667,7 +652,8 @@ button, .btn { } #hub-set-account:checked ~ .settings-tabs label[for="hub-set-account"], #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); background: rgba(34, 211, 238, 0.12); 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-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; } .settings-grid { diff --git a/static/instances.js b/static/instances.js deleted file mode 100644 index f57a4b3..0000000 --- a/static/instances.js +++ /dev/null @@ -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(); - } -})(); diff --git a/static/settings_update.js b/static/settings_update.js new file mode 100644 index 0000000..130ee2f --- /dev/null +++ b/static/settings_update.js @@ -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, """); + } + + 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 = '加载中…'; + try { + const res = await fetchJson("/api/settings/update/clients"); + const items = (res.data && res.data.items) || []; + if (!items.length) { + body.innerHTML = '暂无登记实例'; + return; + } + body.innerHTML = items + .map(function (it) { + const id = it.id; + const name = it.name || "#" + id; + const enabled = !!it.enabled; + return ( + '' + + "" + + escapeHtml(name) + + "
" + + escapeHtml(it.base_url || "") + + "
" + + '—' + + "" + + (enabled ? "启用" : '停用') + + "" + + '' + + ' " + + '' + + ' ' + + "" + + "" + ); + }) + .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 = + '' + escapeHtml((e && e.message) || "加载失败") + ""; + } + } + + 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); diff --git a/templates/base.html b/templates/base.html index b586573..a5d2a33 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 23955ad..c276d02 100644 --- a/templates/instances.html +++ b/templates/instances.html @@ -23,20 +23,12 @@

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

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

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

@@ -53,10 +45,11 @@
+

+ 内嵌打开 + 系统更新 +

{% endfor %} {% endblock %} -{% block scripts %} - -{% endblock %} diff --git a/templates/settings.html b/templates/settings.html index b42fcb8..c166480 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -2,12 +2,12 @@ {% block title %}系统设置 · 期权中控{% endblock %} {% block body %} {% 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 %}

Hub Settings

系统设置

-

账号、告警与备份在此维护;保存后立即生效。

+

账号、告警、备份与系统更新在此维护;保存后立即生效。

@@ -15,11 +15,13 @@ +
+
@@ -138,10 +140,52 @@
+ +
+
+ Update +

系统更新

+

快速更新 = git pull + pm2 restart。中控本机更新;客户端经 Bridge 远程下发。有持仓时慎用。

+
+ +
+
+
+

中控更新

+

当前版本 —

+
+ + + +
+
+
+ +
+
+

客户端远程更新

+

经 Bridge 探测版本并下发更新;客户端需已具备 /api/hub/update

+
+ +
+
+
+ + + +
实例版本状态操作
加载中…
+
+
+
+
{% endblock %} {% block scripts %} - + + {% endblock %}