系统更新增加客户端「全部更新」,依次远程更新所有启用实例。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-10-04 10:19:00 +08:00
parent 50963a25fc
commit 6bc69ab371
2 changed files with 403 additions and 340 deletions
+400 -339
View File
@@ -1,339 +1,400 @@
(function (global) { (function (global) {
function sleep(ms) { function sleep(ms) {
return new Promise(function (resolve) { return new Promise(function (resolve) {
setTimeout(resolve, ms); setTimeout(resolve, ms);
}); });
} }
async function fetchJson(url, opts) { async function fetchJson(url, opts) {
const base = opts || {}; const base = opts || {};
const headers = Object.assign({ Accept: "application/json" }, base.headers || {}); const headers = Object.assign({ Accept: "application/json" }, base.headers || {});
try { try {
const r = await fetch( const r = await fetch(
url, url,
Object.assign({}, base, { Object.assign({}, base, {
credentials: "same-origin", credentials: "same-origin",
headers: headers, headers: headers,
}) })
); );
const text = await r.text(); const text = await r.text();
let data = {}; let data = {};
try { try {
data = text ? JSON.parse(text) : {}; data = text ? JSON.parse(text) : {};
} catch (e) { } catch (e) {
data = { data = {
ok: false, ok: false,
msg: "非 JSON 响应 HTTP " + r.status + (text ? ": " + String(text).slice(0, 120) : ""), msg: "非 JSON 响应 HTTP " + r.status + (text ? ": " + String(text).slice(0, 120) : ""),
}; };
} }
if (!data || typeof data !== "object") data = {}; if (!data || typeof data !== "object") data = {};
if (data.ok == null && !r.ok) { if (data.ok == null && !r.ok) {
data.ok = false; data.ok = false;
data.msg = data.msg || "请求失败 HTTP " + r.status; data.msg = data.msg || "请求失败 HTTP " + r.status;
} }
return { httpOk: r.ok, status: r.status, data: data }; return { httpOk: r.ok, status: r.status, data: data };
} catch (e) { } catch (e) {
return { return {
httpOk: false, httpOk: false,
status: 0, status: 0,
data: { ok: false, msg: (e && e.message) || "网络错误" }, data: { ok: false, msg: (e && e.message) || "网络错误" },
}; };
} }
} }
function escapeHtml(s) { function escapeHtml(s) {
return String(s) return String(s)
.replace(/&/g, "&amp;") .replace(/&/g, "&amp;")
.replace(/</g, "&lt;") .replace(/</g, "&lt;")
.replace(/>/g, "&gt;") .replace(/>/g, "&gt;")
.replace(/"/g, "&quot;"); .replace(/"/g, "&quot;");
} }
function fmtVer(v) { function fmtVer(v) {
if (!v) return "—"; if (!v) return "—";
if (v.online === false) return v.msg || "离线"; if (v.online === false) return v.msg || "离线";
const short = v.short_sha || (v.sha ? String(v.sha).slice(0, 7) : ""); const short = v.short_sha || (v.sha ? String(v.sha).slice(0, 7) : "");
const branch = v.branch ? v.branch + " · " : ""; const branch = v.branch ? v.branch + " · " : "";
if (!short) return v.msg || (v.ok ? "未知版本" : "不可用"); if (!short) return v.msg || (v.ok ? "未知版本" : "不可用");
return branch + short + (v.dirty ? " (dirty)" : ""); return branch + short + (v.dirty ? " (dirty)" : "");
} }
function setStatus(el, text, cls) { function setStatus(el, text, cls) {
if (!el) return; if (!el) return;
el.textContent = text || ""; el.textContent = text || "";
el.className = "update-status" + (cls ? " " + cls : ""); el.className = "update-status" + (cls ? " " + cls : "");
} }
async function waitRecover(versionUrl, beforeSha, statusEl, label, opts) { async function waitRecover(versionUrl, beforeSha, statusEl, label, opts) {
const options = opts || {}; const options = opts || {};
const expectSha = options.expectSha || ""; const expectSha = options.expectSha || "";
const alreadyLatest = !!options.alreadyLatest; const alreadyLatest = !!options.alreadyLatest;
let sawDown = false; let sawDown = false;
let stableOnline = 0; let stableOnline = 0;
const maxTries = 30; const maxTries = 30;
for (let i = 0; i < maxTries; i++) { for (let i = 0; i < maxTries; i++) {
await sleep(1500); await sleep(1500);
const res = await fetchJson(versionUrl); const res = await fetchJson(versionUrl);
const v = res.data || {}; const v = res.data || {};
const online = res.httpOk && (v.ok !== false) && (v.online !== false); const online = res.httpOk && (v.ok !== false) && (v.online !== false);
if (!online) { if (!online) {
sawDown = true; sawDown = true;
stableOnline = 0; stableOnline = 0;
setStatus(statusEl, "重启中 → 等待上线… (" + (i + 1) + "/" + maxTries + ")", "pending"); setStatus(statusEl, "重启中 → 等待上线… (" + (i + 1) + "/" + maxTries + ")", "pending");
continue; continue;
} }
const afterSha = v.sha || ""; const afterSha = v.sha || "";
const afterShort = v.short_sha || (afterSha ? afterSha.slice(0, 7) : ""); const afterShort = v.short_sha || (afterSha ? afterSha.slice(0, 7) : "");
const beforeShort = beforeSha ? String(beforeSha).slice(0, 7) : ""; const beforeShort = beforeSha ? String(beforeSha).slice(0, 7) : "";
if (expectSha && afterSha && afterSha === expectSha) { if (expectSha && afterSha && afterSha === expectSha) {
setStatus( setStatus(
statusEl, statusEl,
(label || "更新完成") + (label || "更新完成") +
(beforeSha && beforeSha !== afterSha (beforeSha && beforeSha !== afterSha
? ": " + (beforeShort || "?") + " → " + (afterShort || "?") ? ": " + (beforeShort || "?") + " → " + (afterShort || "?")
: " " + (afterShort || "")), : " " + (afterShort || "")),
"ok" "ok"
); );
return v; return v;
} }
if (afterSha && beforeSha && afterSha !== beforeSha) { if (afterSha && beforeSha && afterSha !== beforeSha) {
setStatus(statusEl, (label || "更新完成") + ": " + (beforeShort || "?") + " → " + (afterShort || "?"), "ok"); setStatus(statusEl, (label || "更新完成") + ": " + (beforeShort || "?") + " → " + (afterShort || "?"), "ok");
return v; return v;
} }
if (sawDown) { if (sawDown) {
setStatus( setStatus(
statusEl, statusEl,
afterSha && beforeSha && afterSha === beforeSha afterSha && beforeSha && afterSha === beforeSha
? "已重启上线(已是最新 " + (afterShort || "") + ")" ? "已重启上线(已是最新 " + (afterShort || "") + ")"
: "已上线 " + (afterShort || ""), : "已上线 " + (afterShort || ""),
"ok" "ok"
); );
return v; return v;
} }
stableOnline += 1; stableOnline += 1;
// 重启很快时可能看不到掉线:连续在线数次且版本符合预期/未变 → 视为完成 // 重启很快时可能看不到掉线:连续在线数次且版本符合预期/未变 → 视为完成
if (stableOnline >= 4) { if (stableOnline >= 4) {
if (alreadyLatest || (afterSha && beforeSha && afterSha === beforeSha)) { if (alreadyLatest || (afterSha && beforeSha && afterSha === beforeSha)) {
setStatus(statusEl, "完成(已是最新 " + (afterShort || "") + ",进程已重启)", "ok"); setStatus(statusEl, "完成(已是最新 " + (afterShort || "") + ",进程已重启)", "ok");
return v; return v;
} }
if (expectSha && afterSha === expectSha) { if (expectSha && afterSha === expectSha) {
setStatus(statusEl, (label || "更新完成") + " " + (afterShort || ""), "ok"); setStatus(statusEl, (label || "更新完成") + " " + (afterShort || ""), "ok");
return v; return v;
} }
} }
setStatus(statusEl, "等待重启生效… (" + (i + 1) + "/" + maxTries + ")", "pending"); setStatus(statusEl, "等待重启生效… (" + (i + 1) + "/" + maxTries + ")", "pending");
} }
setStatus(statusEl, "超时未确认,请点「刷新版本」或查看 logs/hub_update.log", "err"); setStatus(statusEl, "超时未确认,请点「刷新版本」或查看 logs/hub_update.log", "err");
return null; return null;
} }
async function refreshHubVersion() { async function refreshHubVersion() {
const verEl = document.getElementById("hub-update-version"); const verEl = document.getElementById("hub-update-version");
if (!verEl) return null; if (!verEl) return null;
verEl.textContent = "探测中…"; verEl.textContent = "探测中…";
verEl.className = "update-version"; verEl.className = "update-version";
const res = await fetchJson("/api/settings/update/hub/version"); const res = await fetchJson("/api/settings/update/hub/version");
const v = res.data || {}; const v = res.data || {};
if (res.httpOk && v.ok) { if (res.httpOk && v.ok) {
verEl.textContent = fmtVer(v); verEl.textContent = fmtVer(v);
verEl.className = "update-version online"; verEl.className = "update-version online";
} else { } else {
verEl.textContent = v.msg || "读取失败"; verEl.textContent = v.msg || "读取失败";
verEl.className = "update-version offline"; verEl.className = "update-version offline";
} }
return v; return v;
} }
async function runHubUpdate() { async function runHubUpdate() {
const btn = document.getElementById("hub-update-btn"); const btn = document.getElementById("hub-update-btn");
const statusEl = document.getElementById("hub-update-status"); const statusEl = document.getElementById("hub-update-status");
if (!btn) return; if (!btn) return;
if ( if (
!window.confirm( !window.confirm(
"确认更新中控?\n\n将执行 git pull + pm2 restart。\n页面会短暂不可用,完成后自动探测版本。" "确认更新中控?\n\n将执行 git pull + pm2 restart。\n页面会短暂不可用,完成后自动探测版本。"
) )
) { ) {
return; return;
} }
btn.disabled = true; btn.disabled = true;
setStatus(statusEl, "读取当前版本…", "pending"); setStatus(statusEl, "读取当前版本…", "pending");
const before = await refreshHubVersion(); const before = await refreshHubVersion();
const beforeSha = (before && before.sha) || ""; const beforeSha = (before && before.sha) || "";
setStatus(statusEl, "下发更新指令…", "pending"); setStatus(statusEl, "下发更新指令…", "pending");
const res = await fetchJson("/api/settings/update/hub", { const res = await fetchJson("/api/settings/update/hub", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" }, headers: { "Content-Type": "application/json", Accept: "application/json" },
body: "{}", body: "{}",
}); });
const data = res.data || {}; const data = res.data || {};
if (!data.ok) { if (!data.ok) {
const detail = const detail =
data.msg || data.msg ||
(res.status ? "更新失败 HTTP " + res.status : "更新失败(无响应)"); (res.status ? "更新失败 HTTP " + res.status : "更新失败(无响应)");
setStatus(statusEl, detail, "err"); setStatus(statusEl, detail, "err");
btn.disabled = false; btn.disabled = false;
return; return;
} }
setStatus(statusEl, data.msg || "已拉取,等待重启…", "pending"); setStatus(statusEl, data.msg || "已拉取,等待重启…", "pending");
try { try {
await waitRecover( await waitRecover(
"/api/settings/update/hub/version", "/api/settings/update/hub/version",
beforeSha || data.before_sha || "", beforeSha || data.before_sha || "",
statusEl, statusEl,
"中控更新完成", "中控更新完成",
{ {
expectSha: data.after_sha || "", expectSha: data.after_sha || "",
alreadyLatest: !data.changed, alreadyLatest: !data.changed,
} }
); );
await refreshHubVersion(); await refreshHubVersion();
} finally { } finally {
btn.disabled = false; btn.disabled = false;
} }
} }
async function refreshClientRow(row) { async function refreshClientRow(row) {
const iid = row.getAttribute("data-id"); const iid = row.getAttribute("data-id");
const verEl = row.querySelector("[data-client-version]"); const verEl = row.querySelector("[data-client-version]");
if (!iid || !verEl) return null; if (!iid || !verEl) return null;
verEl.textContent = "探测中…"; verEl.textContent = "探测中…";
verEl.className = "update-version"; verEl.className = "update-version";
const res = await fetchJson("/api/instances/" + iid + "/version"); const res = await fetchJson("/api/instances/" + iid + "/version");
const v = res.data || {}; const v = res.data || {};
if (v.online) { if (v.online) {
verEl.textContent = fmtVer(Object.assign({ online: true }, v)); verEl.textContent = fmtVer(Object.assign({ online: true }, v));
verEl.className = "update-version online"; verEl.className = "update-version online";
} else { } else {
verEl.textContent = v.msg || "离线/不可达"; verEl.textContent = v.msg || "离线/不可达";
verEl.className = "update-version offline"; verEl.className = "update-version offline";
} }
return v; return v;
} }
async function runClientUpdate(row) { async function runClientUpdate(row, opts) {
const iid = row.getAttribute("data-id"); const options = opts || {};
const name = row.getAttribute("data-name") || ("#" + iid); const iid = row.getAttribute("data-id");
const btn = row.querySelector("[data-client-update]"); const name = row.getAttribute("data-name") || ("#" + iid);
const statusEl = row.querySelector("[data-client-status]"); const btn = row.querySelector("[data-client-update]");
if (!iid || !btn) return; const statusEl = row.querySelector("[data-client-status]");
if ( if (!iid || !btn) return { ok: false, name: name, msg: "无效行" };
!window.confirm( if (btn.disabled && !options.forceEnabled) {
"确认远程更新「" + return { ok: false, name: name, msg: "未启用或忙碌中" };
name + }
"」?\n\n将执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。" if (
) !options.skipConfirm &&
) { !window.confirm(
return; "确认远程更新「" +
} name +
btn.disabled = true; "」?\n\n将执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。"
setStatus(statusEl, "读取当前版本…", "pending"); )
const before = await refreshClientRow(row); ) {
const beforeSha = (before && before.sha) || ""; return { ok: false, name: name, msg: "已取消" };
setStatus(statusEl, "下发更新指令…", "pending"); }
const res = await fetchJson("/api/instances/" + iid + "/update", { btn.disabled = true;
method: "POST", setStatus(statusEl, "读取当前版本…", "pending");
headers: { "Content-Type": "application/json", Accept: "application/json" }, const before = await refreshClientRow(row);
body: "{}", const beforeSha = (before && before.sha) || "";
}); setStatus(statusEl, "下发更新指令…", "pending");
const data = res.data || {}; const res = await fetchJson("/api/instances/" + iid + "/update", {
if (!data.ok) { method: "POST",
setStatus( headers: { "Content-Type": "application/json", Accept: "application/json" },
statusEl, body: "{}",
data.msg || (res.status ? "更新失败 HTTP " + res.status : "更新失败"), });
"err" const data = res.data || {};
); if (!data.ok) {
btn.disabled = false; const msg =
return; data.msg || (res.status ? "更新失败 HTTP " + res.status : "更新失败");
} setStatus(statusEl, msg, "err");
setStatus(statusEl, "已受理 → 等待上线…", "pending"); btn.disabled = false;
try { return { ok: false, name: name, msg: msg };
await waitRecover( }
"/api/instances/" + iid + "/version", setStatus(statusEl, "已受理 → 等待上线…", "pending");
beforeSha || data.before_sha || "", try {
statusEl, await waitRecover(
"更新完成" "/api/instances/" + iid + "/version",
); beforeSha || data.before_sha || "",
await refreshClientRow(row); statusEl,
} finally { "更新完成"
btn.disabled = false; );
} await refreshClientRow(row);
} return { ok: true, name: name };
} finally {
async function loadClients() { btn.disabled = false;
const body = document.getElementById("client-update-body"); }
if (!body) return; }
body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
try { async function runAllClientUpdates() {
const res = await fetchJson("/api/settings/update/clients"); const allBtn = document.getElementById("client-update-all");
const items = (res.data && res.data.items) || []; const allStatus = document.getElementById("client-update-all-status");
if (!items.length) { const rows = Array.prototype.slice
body.innerHTML = '<tr><td colspan="4" class="muted">暂无登记实例</td></tr>'; .call(document.querySelectorAll(".client-update-row"))
return; .filter(function (row) {
} const btn = row.querySelector("[data-client-update]");
body.innerHTML = items return btn && !btn.disabled;
.map(function (it) { });
const id = it.id; if (!rows.length) {
const name = it.name || "#" + id; setStatus(allStatus, "没有可更新的启用实例", "err");
const enabled = !!it.enabled; return;
return ( }
'<tr class="client-update-row" data-id="' + if (
escapeHtml(String(id)) + !window.confirm(
'" data-name="' + "确认远程更新全部 " +
escapeHtml(name) + rows.length +
'">' + " 台启用实例?\n\n将依次执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。"
"<td><strong>" + )
escapeHtml(name) + ) {
"</strong><div class=\"muted\" style=\"font-size:.75rem\">" + return;
escapeHtml(it.base_url || "") + }
"</div></td>" + if (allBtn) allBtn.disabled = true;
'<td><span class="update-version muted" data-client-version>—</span></td>' + const refreshBtn = document.getElementById("client-update-refresh");
"<td>" + if (refreshBtn) refreshBtn.disabled = true;
(enabled ? "启用" : '<span class="muted">停用</span>') + let okCount = 0;
"</td>" + let failCount = 0;
'<td style="white-space:nowrap">' + try {
'<button type="button" class="btn btn-sm" data-client-update' + for (let i = 0; i < rows.length; i++) {
(enabled ? "" : " disabled title=\"实例未启用\"") + const row = rows[i];
">远程更新</button> " + const name = row.getAttribute("data-name") || "#" + (i + 1);
'<button type="button" class="btn btn-sm btn-secondary" data-client-refresh>刷新版本</button>' + setStatus(
' <span class="update-status muted" data-client-status></span>' + allStatus,
"</td>" + "全部更新中 " + (i + 1) + "/" + rows.length + " · " + name,
"</tr>" "pending"
); );
}) const result = await runClientUpdate(row, { skipConfirm: true });
.join(""); if (result && result.ok) okCount += 1;
else failCount += 1;
body.querySelectorAll(".client-update-row").forEach(function (row) { }
const upd = row.querySelector("[data-client-update]"); if (failCount === 0) {
const ref = row.querySelector("[data-client-refresh]"); setStatus(allStatus, "全部更新完成 · " + okCount + " 台", "ok");
if (upd) { } else {
upd.addEventListener("click", function () { setStatus(
runClientUpdate(row); allStatus,
}); "全部更新结束 · 成功 " + okCount + " · 失败 " + failCount,
} "err"
if (ref) { );
ref.addEventListener("click", function () { }
refreshClientRow(row); } finally {
}); if (allBtn) allBtn.disabled = false;
} if (refreshBtn) refreshBtn.disabled = false;
refreshClientRow(row); }
}); }
} catch (e) {
body.innerHTML = async function loadClients() {
'<tr><td colspan="4" class="muted">' + escapeHtml((e && e.message) || "加载失败") + "</td></tr>"; const body = document.getElementById("client-update-body");
} if (!body) return;
} body.innerHTML = '<tr><td colspan="4" class="muted">加载中…</td></tr>';
try {
function boot() { const res = await fetchJson("/api/settings/update/clients");
const hubBtn = document.getElementById("hub-update-btn"); const items = (res.data && res.data.items) || [];
const hubRefresh = document.getElementById("hub-update-refresh"); if (!items.length) {
const clientsRefresh = document.getElementById("client-update-refresh"); body.innerHTML = '<tr><td colspan="4" class="muted">暂无登记实例</td></tr>';
if (hubBtn) hubBtn.addEventListener("click", runHubUpdate); return;
if (hubRefresh) hubRefresh.addEventListener("click", refreshHubVersion); }
if (clientsRefresh) clientsRefresh.addEventListener("click", loadClients); body.innerHTML = items
refreshHubVersion(); .map(function (it) {
loadClients(); const id = it.id;
} const name = it.name || "#" + id;
const enabled = !!it.enabled;
global.HubSettingsUpdate = { boot: boot }; return (
})(window); '<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");
const clientsAll = document.getElementById("client-update-all");
if (hubBtn) hubBtn.addEventListener("click", runHubUpdate);
if (hubRefresh) hubRefresh.addEventListener("click", refreshHubVersion);
if (clientsRefresh) clientsRefresh.addEventListener("click", loadClients);
if (clientsAll) clientsAll.addEventListener("click", runAllClientUpdates);
refreshHubVersion();
loadClients();
}
global.HubSettingsUpdate = { boot: boot };
})(window);
+3 -1
View File
@@ -166,7 +166,9 @@
<h3>客户端远程更新</h3> <h3>客户端远程更新</h3>
<p class="muted field-hint">经 Bridge 探测版本并下发更新;客户端需已具备 <code>/api/hub/update</code></p> <p class="muted field-hint">经 Bridge 探测版本并下发更新;客户端需已具备 <code>/api/hub/update</code></p>
<div class="settings-actions"> <div class="settings-actions">
<button type="button" class="settings-primary" id="client-update-all">全部更新</button>
<button type="button" class="btn-secondary" id="client-update-refresh">刷新列表</button> <button type="button" class="btn-secondary" id="client-update-refresh">刷新列表</button>
<span class="update-status muted" id="client-update-all-status"></span>
</div> </div>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
@@ -183,7 +185,7 @@
{% 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 src="{{ url_for('static', filename='settings_update.js') }}?v=4"></script> <script src="{{ url_for('static', filename='settings_update.js') }}?v=5"></script>
<script> <script>
HubSettingsBackup.boot(); HubSettingsBackup.boot();
HubSettingsUpdate.boot(); HubSettingsUpdate.boot();