From 6bc69ab3712a156342f72bacc9189aa962abc613 Mon Sep 17 00:00:00 2001 From: dekun Date: Sun, 4 Oct 2026 10:19:00 +0800 Subject: [PATCH] =?UTF-8?q?=E7=B3=BB=E7=BB=9F=E6=9B=B4=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8A=A0=E5=AE=A2=E6=88=B7=E7=AB=AF=E3=80=8C=E5=85=A8=E9=83=A8?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E3=80=8D=EF=BC=8C=E4=BE=9D=E6=AC=A1=E8=BF=9C?= =?UTF-8?q?=E7=A8=8B=E6=9B=B4=E6=96=B0=E6=89=80=E6=9C=89=E5=90=AF=E7=94=A8?= =?UTF-8?q?=E5=AE=9E=E4=BE=8B=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- static/settings_update.js | 739 +++++++++++++++++++++----------------- templates/settings.html | 4 +- 2 files changed, 403 insertions(+), 340 deletions(-) diff --git a/static/settings_update.js b/static/settings_update.js index ad968cd..780e517 100644 --- a/static/settings_update.js +++ b/static/settings_update.js @@ -1,339 +1,400 @@ -(function (global) { - function sleep(ms) { - return new Promise(function (resolve) { - setTimeout(resolve, ms); - }); - } - - async function fetchJson(url, opts) { - const base = opts || {}; - const headers = Object.assign({ Accept: "application/json" }, base.headers || {}); - try { - const r = await fetch( - url, - Object.assign({}, base, { - credentials: "same-origin", - headers: headers, - }) - ); - const text = await r.text(); - let data = {}; - try { - data = text ? JSON.parse(text) : {}; - } catch (e) { - data = { - ok: false, - msg: "非 JSON 响应 HTTP " + r.status + (text ? ": " + String(text).slice(0, 120) : ""), - }; - } - if (!data || typeof data !== "object") data = {}; - if (data.ok == null && !r.ok) { - data.ok = false; - data.msg = data.msg || "请求失败 HTTP " + r.status; - } - return { httpOk: r.ok, status: r.status, data: data }; - } catch (e) { - return { - httpOk: false, - status: 0, - data: { ok: false, msg: (e && e.message) || "网络错误" }, - }; - } - } - - 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, opts) { - const options = opts || {}; - const expectSha = options.expectSha || ""; - const alreadyLatest = !!options.alreadyLatest; - let sawDown = false; - let stableOnline = 0; - const maxTries = 30; - for (let i = 0; i < maxTries; i++) { - await sleep(1500); - const res = await fetchJson(versionUrl); - const v = res.data || {}; - const online = res.httpOk && (v.ok !== false) && (v.online !== false); - if (!online) { - sawDown = true; - stableOnline = 0; - 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 (expectSha && afterSha && afterSha === expectSha) { - setStatus( - statusEl, - (label || "更新完成") + - (beforeSha && beforeSha !== afterSha - ? ": " + (beforeShort || "?") + " → " + (afterShort || "?") - : " " + (afterShort || "")), - "ok" - ); - return v; - } - 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; - } - stableOnline += 1; - // 重启很快时可能看不到掉线:连续在线数次且版本符合预期/未变 → 视为完成 - if (stableOnline >= 4) { - if (alreadyLatest || (afterSha && beforeSha && afterSha === beforeSha)) { - setStatus(statusEl, "完成(已是最新 " + (afterShort || "") + ",进程已重启)", "ok"); - return v; - } - if (expectSha && afterSha === expectSha) { - setStatus(statusEl, (label || "更新完成") + " " + (afterShort || ""), "ok"); - return v; - } - } - setStatus(statusEl, "等待重启生效… (" + (i + 1) + "/" + maxTries + ")", "pending"); - } - setStatus(statusEl, "超时未确认,请点「刷新版本」或查看 logs/hub_update.log", "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) { - const detail = - data.msg || - (res.status ? "更新失败 HTTP " + res.status : "更新失败(无响应)"); - setStatus(statusEl, detail, "err"); - btn.disabled = false; - return; - } - setStatus(statusEl, data.msg || "已拉取,等待重启…", "pending"); - try { - await waitRecover( - "/api/settings/update/hub/version", - beforeSha || data.before_sha || "", - statusEl, - "中控更新完成", - { - expectSha: data.after_sha || "", - alreadyLatest: !data.changed, - } - ); - 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 || (res.status ? "更新失败 HTTP " + res.status : "更新失败"), - "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); +(function (global) { + function sleep(ms) { + return new Promise(function (resolve) { + setTimeout(resolve, ms); + }); + } + + async function fetchJson(url, opts) { + const base = opts || {}; + const headers = Object.assign({ Accept: "application/json" }, base.headers || {}); + try { + const r = await fetch( + url, + Object.assign({}, base, { + credentials: "same-origin", + headers: headers, + }) + ); + const text = await r.text(); + let data = {}; + try { + data = text ? JSON.parse(text) : {}; + } catch (e) { + data = { + ok: false, + msg: "非 JSON 响应 HTTP " + r.status + (text ? ": " + String(text).slice(0, 120) : ""), + }; + } + if (!data || typeof data !== "object") data = {}; + if (data.ok == null && !r.ok) { + data.ok = false; + data.msg = data.msg || "请求失败 HTTP " + r.status; + } + return { httpOk: r.ok, status: r.status, data: data }; + } catch (e) { + return { + httpOk: false, + status: 0, + data: { ok: false, msg: (e && e.message) || "网络错误" }, + }; + } + } + + 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, opts) { + const options = opts || {}; + const expectSha = options.expectSha || ""; + const alreadyLatest = !!options.alreadyLatest; + let sawDown = false; + let stableOnline = 0; + const maxTries = 30; + for (let i = 0; i < maxTries; i++) { + await sleep(1500); + const res = await fetchJson(versionUrl); + const v = res.data || {}; + const online = res.httpOk && (v.ok !== false) && (v.online !== false); + if (!online) { + sawDown = true; + stableOnline = 0; + 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 (expectSha && afterSha && afterSha === expectSha) { + setStatus( + statusEl, + (label || "更新完成") + + (beforeSha && beforeSha !== afterSha + ? ": " + (beforeShort || "?") + " → " + (afterShort || "?") + : " " + (afterShort || "")), + "ok" + ); + return v; + } + 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; + } + stableOnline += 1; + // 重启很快时可能看不到掉线:连续在线数次且版本符合预期/未变 → 视为完成 + if (stableOnline >= 4) { + if (alreadyLatest || (afterSha && beforeSha && afterSha === beforeSha)) { + setStatus(statusEl, "完成(已是最新 " + (afterShort || "") + ",进程已重启)", "ok"); + return v; + } + if (expectSha && afterSha === expectSha) { + setStatus(statusEl, (label || "更新完成") + " " + (afterShort || ""), "ok"); + return v; + } + } + setStatus(statusEl, "等待重启生效… (" + (i + 1) + "/" + maxTries + ")", "pending"); + } + setStatus(statusEl, "超时未确认,请点「刷新版本」或查看 logs/hub_update.log", "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) { + const detail = + data.msg || + (res.status ? "更新失败 HTTP " + res.status : "更新失败(无响应)"); + setStatus(statusEl, detail, "err"); + btn.disabled = false; + return; + } + setStatus(statusEl, data.msg || "已拉取,等待重启…", "pending"); + try { + await waitRecover( + "/api/settings/update/hub/version", + beforeSha || data.before_sha || "", + statusEl, + "中控更新完成", + { + expectSha: data.after_sha || "", + alreadyLatest: !data.changed, + } + ); + 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, opts) { + const options = opts || {}; + 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 { ok: false, name: name, msg: "无效行" }; + if (btn.disabled && !options.forceEnabled) { + return { ok: false, name: name, msg: "未启用或忙碌中" }; + } + if ( + !options.skipConfirm && + !window.confirm( + "确认远程更新「" + + name + + "」?\n\n将执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。" + ) + ) { + return { ok: false, name: name, msg: "已取消" }; + } + 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) { + const msg = + data.msg || (res.status ? "更新失败 HTTP " + res.status : "更新失败"); + setStatus(statusEl, msg, "err"); + btn.disabled = false; + return { ok: false, name: name, msg: msg }; + } + setStatus(statusEl, "已受理 → 等待上线…", "pending"); + try { + await waitRecover( + "/api/instances/" + iid + "/version", + beforeSha || data.before_sha || "", + statusEl, + "更新完成" + ); + await refreshClientRow(row); + return { ok: true, name: name }; + } finally { + btn.disabled = false; + } + } + + async function runAllClientUpdates() { + const allBtn = document.getElementById("client-update-all"); + const allStatus = document.getElementById("client-update-all-status"); + const rows = Array.prototype.slice + .call(document.querySelectorAll(".client-update-row")) + .filter(function (row) { + const btn = row.querySelector("[data-client-update]"); + return btn && !btn.disabled; + }); + if (!rows.length) { + setStatus(allStatus, "没有可更新的启用实例", "err"); + return; + } + if ( + !window.confirm( + "确认远程更新全部 " + + rows.length + + " 台启用实例?\n\n将依次执行 git pull + pm2 restart。\n有持仓时服务会短暂中断,请谨慎。" + ) + ) { + return; + } + if (allBtn) allBtn.disabled = true; + const refreshBtn = document.getElementById("client-update-refresh"); + if (refreshBtn) refreshBtn.disabled = true; + let okCount = 0; + let failCount = 0; + try { + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const name = row.getAttribute("data-name") || "#" + (i + 1); + setStatus( + allStatus, + "全部更新中 " + (i + 1) + "/" + rows.length + " · " + name, + "pending" + ); + const result = await runClientUpdate(row, { skipConfirm: true }); + if (result && result.ok) okCount += 1; + else failCount += 1; + } + if (failCount === 0) { + setStatus(allStatus, "全部更新完成 · " + okCount + " 台", "ok"); + } else { + setStatus( + allStatus, + "全部更新结束 · 成功 " + okCount + " · 失败 " + failCount, + "err" + ); + } + } finally { + if (allBtn) allBtn.disabled = false; + if (refreshBtn) refreshBtn.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"); + 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); diff --git a/templates/settings.html b/templates/settings.html index 226b905..a5acb30 100644 --- a/templates/settings.html +++ b/templates/settings.html @@ -166,7 +166,9 @@

客户端远程更新

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

+ +
@@ -183,7 +185,7 @@ {% endblock %} {% block scripts %} - +